From 82cbb3670390e41ce2cec9097891ea56996ea04d Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 07:46:32 -0500
Subject: [PATCH 01/11] refactor: abstract file loading (#1)
---
taskfile/loader.go | 43 ++++++++++++++++++++++++++++++++
taskfile/loader_test.go | 23 ++++++++++++++++++
taskfile/reader.go | 54 ++++++++++++++++++++++++++++-------------
3 files changed, 103 insertions(+), 17 deletions(-)
create mode 100644 taskfile/loader.go
create mode 100644 taskfile/loader_test.go
diff --git a/taskfile/loader.go b/taskfile/loader.go
new file mode 100644
index 0000000000..9fa6805e58
--- /dev/null
+++ b/taskfile/loader.go
@@ -0,0 +1,43 @@
+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
+}
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/reader.go b/taskfile/reader.go
index 3f36ad62b2..e2c8dbc3b5 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,11 @@ type (
// options.
func NewReader(opts ...ReaderOption) *Reader {
r := &Reader{
- graph: ast.NewTaskfileGraph(),
+ graph: ast.NewTaskfileGraph(),
+ loaders: map[string]Loader{
+ ".yml": YAMLLoader{},
+ ".yaml": YAMLLoader{},
+ },
insecure: false,
download: false,
offline: false,
@@ -119,6 +124,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 {
@@ -324,19 +347,16 @@ 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())
- }
- return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err}
+ 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 {
+ return nil, err
}
// Check that the Taskfile is set and has a schema version
@@ -357,7 +377,7 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error)
}
}
- return &tf, nil
+ return tf, nil
}
func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) {
From 85af1bfcdbb27da76ddb3f02cd0de249f35bebe8 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 08:17:05 -0500
Subject: [PATCH 02/11] feat: cover hcl discovery - task 2 (#2)
---
completion/fish/task.fish | 2 +-
completion/zsh/_task | 4 +--
internal/flags/flags.go | 4 +--
taskfile/discovery_test.go | 62 ++++++++++++++++++++++++++++++++++++++
taskfile/loader.go | 24 +++++++++++----
taskfile/node_http.go | 9 ++++--
taskfile/reader.go | 25 +++++++--------
taskfile/taskfile.go | 41 ++++++++++++++-----------
8 files changed, 129 insertions(+), 42 deletions(-)
create mode 100644 taskfile/discovery_test.go
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/internal/flags/flags.go b/internal/flags/flags.go
index dab9fdf8f4..7dd9e43d15 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -128,7 +128,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 +136,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/taskfile/discovery_test.go b/taskfile/discovery_test.go
new file mode 100644
index 0000000000..886cf79adc
--- /dev/null
+++ b/taskfile/discovery_test.go
@@ -0,0 +1,62 @@
+package taskfile
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "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 || !strings.Contains(err.Error(), "HCL parsing not implemented") {
+ t.Fatalf("expected HCL parsing not implemented error, got %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 || !strings.Contains(err.Error(), "HCL parsing not implemented") {
+ t.Fatalf("expected HCL parsing not implemented error, got %v", err)
+ }
+}
diff --git a/taskfile/loader.go b/taskfile/loader.go
index 9fa6805e58..c97bd764be 100644
--- a/taskfile/loader.go
+++ b/taskfile/loader.go
@@ -1,13 +1,13 @@
package taskfile
import (
- stdErrors "errors"
+ stdErrors "errors"
- "gopkg.in/yaml.v3"
+ "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"
+ "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.
@@ -18,7 +18,7 @@ import (
// 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)
+ Load(data []byte, location string) (*ast.Taskfile, error)
}
// YAMLLoader implements [Loader] using YAML as the configuration format.
@@ -41,3 +41,15 @@ func (YAMLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
}
return &tf, nil
}
+
+// HCLLoader implements [Loader] using HCL as the configuration format.
+//
+// Note: HCL parsing is not yet implemented. This loader currently returns a
+// descriptive error so that discovery logic can recognize HCL Taskfiles without
+// breaking program flow.
+type HCLLoader struct{}
+
+// Load returns a not implemented error until HCL support is completed.
+func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
+ return nil, errors.New("HCL parsing not implemented")
+}
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 e2c8dbc3b5..8b6708363b 100644
--- a/taskfile/reader.go
+++ b/taskfile/reader.go
@@ -60,6 +60,8 @@ func NewReader(opts ...ReaderOption) *Reader {
loaders: map[string]Loader{
".yml": YAMLLoader{},
".yaml": YAMLLoader{},
+ ".hcl": HCLLoader{},
+ "": HCLLoader{},
},
insecure: false,
download: false,
@@ -230,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
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
From de84e84f6003967704f06b22a9f5d328ce81e2b3 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 08:30:30 -0500
Subject: [PATCH 03/11] feat: Add basic HCL parsing - task 3 (#3)
---
go.mod | 11 ++++++-
go.sum | 26 +++++++++++++---
taskfile/discovery_test.go | 13 ++++----
taskfile/hcl_loader_test.go | 48 ++++++++++++++++++++++++++++
taskfile/loader.go | 9 ------
taskfile/loader_hcl.go | 62 +++++++++++++++++++++++++++++++++++++
taskfile/reader.go | 8 +++++
7 files changed, 156 insertions(+), 21 deletions(-)
create mode 100644 taskfile/hcl_loader_test.go
create mode 100644 taskfile/loader_hcl.go
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/taskfile/discovery_test.go b/taskfile/discovery_test.go
index 886cf79adc..761fa4c5ee 100644
--- a/taskfile/discovery_test.go
+++ b/taskfile/discovery_test.go
@@ -4,7 +4,6 @@ import (
"context"
"os"
"path/filepath"
- "strings"
"testing"
)
@@ -33,7 +32,7 @@ func TestDiscoveryPrefersYAMLOverHCL(t *testing.T) {
func TestHCLLoaderInvoked(t *testing.T) {
dir := t.TempDir()
- writeFile(t, dir, "Taskfile.hcl", "version = 3\n")
+ writeFile(t, dir, "Taskfile.hcl", "version = \"3\"\n")
node, err := NewFileNode("", dir)
if err != nil {
@@ -41,14 +40,14 @@ func TestHCLLoaderInvoked(t *testing.T) {
}
r := NewReader()
_, err = r.Read(context.Background(), node)
- if err == nil || !strings.Contains(err.Error(), "HCL parsing not implemented") {
- t.Fatalf("expected HCL parsing not implemented error, got %v", err)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
}
}
func TestExtensionlessTaskfile(t *testing.T) {
dir := t.TempDir()
- writeFile(t, dir, "Taskfile", "version = 3\n")
+ writeFile(t, dir, "Taskfile", "version = \"3\"\n")
node, err := NewFileNode("", dir)
if err != nil {
@@ -56,7 +55,7 @@ func TestExtensionlessTaskfile(t *testing.T) {
}
r := NewReader()
_, err = r.Read(context.Background(), node)
- if err == nil || !strings.Contains(err.Error(), "HCL parsing not implemented") {
- t.Fatalf("expected HCL parsing not implemented error, got %v", err)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
}
}
diff --git a/taskfile/hcl_loader_test.go b/taskfile/hcl_loader_test.go
new file mode 100644
index 0000000000..0241df0102
--- /dev/null
+++ b/taskfile/hcl_loader_test.go
@@ -0,0 +1,48 @@
+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"
+ cmds = ["echo hello"]
+ }
+ `)
+
+ 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.Equal(t, "echo hello", build.Cmds[0].Cmd)
+}
+
+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")
+}
diff --git a/taskfile/loader.go b/taskfile/loader.go
index c97bd764be..bfb945ae80 100644
--- a/taskfile/loader.go
+++ b/taskfile/loader.go
@@ -43,13 +43,4 @@ func (YAMLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
}
// HCLLoader implements [Loader] using HCL as the configuration format.
-//
-// Note: HCL parsing is not yet implemented. This loader currently returns a
-// descriptive error so that discovery logic can recognize HCL Taskfiles without
-// breaking program flow.
type HCLLoader struct{}
-
-// Load returns a not implemented error until HCL support is completed.
-func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
- return nil, errors.New("HCL parsing not implemented")
-}
diff --git a/taskfile/loader_hcl.go b/taskfile/loader_hcl.go
new file mode 100644
index 0000000000..1e4e57c521
--- /dev/null
+++ b/taskfile/loader_hcl.go
@@ -0,0 +1,62 @@
+package taskfile
+
+import (
+ "github.com/Masterminds/semver/v3"
+ "github.com/hashicorp/hcl/v2/gohcl"
+ "github.com/hashicorp/hcl/v2/hclparse"
+
+ "github.com/go-task/task/v3/errors"
+ "github.com/go-task/task/v3/internal/filepathext"
+ "github.com/go-task/task/v3/taskfile/ast"
+)
+
+// hclTaskfile mirrors the HCL structure of a Taskfile.
+type hclTaskfile struct {
+ Version string `hcl:"version,attr"`
+ Tasks []hclTaskBlock `hcl:"task,block"`
+}
+
+// hclTaskBlock represents an individual task block.
+type hclTaskBlock struct {
+ Name string `hcl:"name,label"`
+ Desc *string `hcl:"desc,attr"`
+ Cmds []string `hcl:"cmds,attr"`
+}
+
+// Load parses the given data as HCL into a Taskfile structure.
+func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
+ parser := hclparse.NewParser()
+ file, diags := parser.ParseHCL(data, location)
+ if diags.HasErrors() {
+ return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags}
+ }
+
+ var htf hclTaskfile
+ diags = gohcl.DecodeBody(file.Body, nil, &htf)
+ if diags.HasErrors() {
+ return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags}
+ }
+
+ version, err := semver.NewVersion(htf.Version)
+ if err != nil {
+ return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: err}
+ }
+
+ tf := &ast.Taskfile{
+ Version: version,
+ Tasks: ast.NewTasks(),
+ }
+
+ for _, t := range htf.Tasks {
+ task := &ast.Task{Task: t.Name}
+ if t.Desc != nil {
+ task.Desc = *t.Desc
+ }
+ for _, cmd := range t.Cmds {
+ task.Cmds = append(task.Cmds, &ast.Cmd{Cmd: cmd})
+ }
+ tf.Tasks.Set(t.Name, task)
+ }
+
+ return tf, nil
+}
diff --git a/taskfile/reader.go b/taskfile/reader.go
index 8b6708363b..2970879749 100644
--- a/taskfile/reader.go
+++ b/taskfile/reader.go
@@ -356,6 +356,14 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error)
}
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
+ }
+ }
+ }
if err != nil {
return nil, err
}
From 103db425d3ea2b31c7704cb2c7daf8889b1cf785 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 08:49:20 -0500
Subject: [PATCH 04/11] feat: hcl expressions - task 4 (#4)
---
taskfile/ast/cmd.go | 3 +
taskfile/ast/task.go | 3 +
taskfile/ast/var.go | 13 ++-
taskfile/hcl_integration_test.go | 27 +++++
taskfile/hcl_loader_test.go | 28 ++++-
taskfile/loader_hcl.go | 174 ++++++++++++++++++++++++++-----
6 files changed, 214 insertions(+), 34 deletions(-)
create mode 100644 taskfile/hcl_integration_test.go
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/hcl_integration_test.go b/taskfile/hcl_integration_test.go
new file mode 100644
index 0000000000..0f42d99211
--- /dev/null
+++ b/taskfile/hcl_integration_test.go
@@ -0,0 +1,27 @@
+package taskfile
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestHCLTaskfileRun(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ data := []byte(`version = "3"
+ task "hello" {
+ cmds = ["echo hi"]
+ }
+`)
+ path := filepath.Join(dir, "Taskfile.hcl")
+ require.NoError(t, os.WriteFile(path, data, 0o644))
+
+ cmd := exec.Command("go", "run", "./cmd/task", "-t", path, "hello")
+ err := cmd.Run()
+ require.Error(t, err)
+}
diff --git a/taskfile/hcl_loader_test.go b/taskfile/hcl_loader_test.go
index 0241df0102..7c0bc10c11 100644
--- a/taskfile/hcl_loader_test.go
+++ b/taskfile/hcl_loader_test.go
@@ -14,7 +14,9 @@ func TestHCLLoader(t *testing.T) {
data := []byte(`version = "3"
task "build" {
desc = "Build the project"
- cmds = ["echo hello"]
+ cmds = ["echo hello ${USER}"]
+ vars = { USER = "world" }
+ env = { GREETING = "hi" }
}
`)
@@ -29,7 +31,15 @@ func TestHCLLoader(t *testing.T) {
require.True(t, ok)
require.Equal(t, "Build the project", build.Desc)
require.Len(t, build.Cmds, 1)
- require.Equal(t, "echo hello", build.Cmds[0].Cmd)
+ 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) {
@@ -46,3 +56,17 @@ func TestHCLLoaderInvalid(t *testing.T) {
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/loader_hcl.go b/taskfile/loader_hcl.go
index 1e4e57c521..34f3957ad2 100644
--- a/taskfile/loader_hcl.go
+++ b/taskfile/loader_hcl.go
@@ -1,62 +1,182 @@
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"
)
-// hclTaskfile mirrors the HCL structure of a Taskfile.
-type hclTaskfile struct {
- Version string `hcl:"version,attr"`
- Tasks []hclTaskBlock `hcl:"task,block"`
-}
-
-// hclTaskBlock represents an individual task block.
-type hclTaskBlock struct {
- Name string `hcl:"name,label"`
- Desc *string `hcl:"desc,attr"`
- Cmds []string `hcl:"cmds,attr"`
-}
-
// 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}
}
- var htf hclTaskfile
- diags = gohcl.DecodeBody(file.Body, nil, &htf)
+ schema := &hcl.BodySchema{
+ Attributes: []hcl.AttributeSchema{
+ {Name: "version", Required: true},
+ {Name: "vars"},
+ {Name: "env"},
+ },
+ Blocks: []hcl.BlockHeaderSchema{
+ {Type: "task", LabelNames: []string{"name"}},
+ },
+ }
+
+ content, diags := file.Body.Content(schema)
if diags.HasErrors() {
return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: diags}
}
- version, err := semver.NewVersion(htf.Version)
+ 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(),
- }
+ tf := &ast.Taskfile{Version: version, Tasks: ast.NewTasks()}
- for _, t := range htf.Tasks {
- task := &ast.Task{Task: t.Name}
- if t.Desc != nil {
- task.Desc = *t.Desc
+ if attr, ok := content.Attributes["vars"]; ok {
+ vars, err := parseVars(attr.Expr, location)
+ if err != nil {
+ return nil, err
}
- for _, cmd := range t.Cmds {
- task.Cmds = append(task.Cmds, &ast.Cmd{Cmd: cmd})
+ tf.Vars = vars
+ }
+ if attr, ok := content.Attributes["env"]; ok {
+ env, err := parseVars(attr.Expr, 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(t.Name, task)
+ 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: "vars"},
+ {Name: "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["vars"]; ok {
+ vars, err := parseVars(attr.Expr, location)
+ if err != nil {
+ return nil, err
+ }
+ t.Vars = vars
+ }
+ if attr, ok := content.Attributes["env"]; ok {
+ env, err := parseVars(attr.Expr, location)
+ if err != nil {
+ return nil, err
+ }
+ t.Env = env
+ }
+
+ return t, nil
+}
+
+func parseVars(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 objectKey(expr hcl.Expression) (string, hcl.Diagnostics) {
+ var key string
+ diags := gohcl.DecodeExpression(expr, nil, &key)
+ return key, diags
+}
From 3e86206a665caf01ec47578c06cb51768d196952 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 09:08:07 -0500
Subject: [PATCH 05/11] feat: Add HCL runtime evaluator - task 5 (#6)
---
compiler.go | 37 +++++++++
internal/hclext/evaluator.go | 128 +++++++++++++++++++++++++++++++
task.go | 16 +++-
taskfile/hcl_evaluator_test.go | 37 +++++++++
taskfile/hcl_integration_test.go | 27 -------
taskfile/reader.go | 21 +++--
variables.go | 30 ++++++--
7 files changed, 252 insertions(+), 44 deletions(-)
create mode 100644 internal/hclext/evaluator.go
create mode 100644 taskfile/hcl_evaluator_test.go
delete mode 100644 taskfile/hcl_integration_test.go
diff --git a/compiler.go b/compiler.go
index 348a072898..90b274f2c5 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,16 +47,48 @@ 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)
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 {
+ if v.Expr != nil {
+ val, err := evaluator.EvalString(v.Expr)
+ if err != nil {
+ return err
+ }
+ result.Set(k, ast.Var{Value: val})
+ evaluator.SetVar(k, val)
+ return nil
+ }
+ if v.ShExpr != nil {
+ if !evaluateShVars {
+ result.Set(k, ast.Var{Value: ""})
+ evaluator.SetVar(k, "")
+ return nil
+ }
+ cmd, err := evaluator.EvalString(v.ShExpr)
+ if err != nil {
+ return err
+ }
+ static, err := c.HandleDynamicVar(ast.Var{Sh: &cmd}, dir, env.GetFromVars(result))
+ if err != nil {
+ return err
+ }
+ result.Set(k, ast.Var{Value: static})
+ evaluator.SetVar(k, static)
+ return nil
+ }
+ }
+
cache := &templater.Cache{Vars: result}
// Replace values
newVar := templater.ReplaceVar(v, cache)
@@ -63,11 +96,13 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
// 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
@@ -77,6 +112,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
// 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
@@ -85,6 +121,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return err
}
result.Set(k, ast.Var{Value: static})
+ evaluator.SetVar(k, static)
return nil
}
}
diff --git a/internal/hclext/evaluator.go b/internal/hclext/evaluator.go
new file mode 100644
index 0000000000..766601196d
--- /dev/null
+++ b/internal/hclext/evaluator.go
@@ -0,0 +1,128 @@
+package hclext
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/hashicorp/hcl/v2"
+ "github.com/zclconf/go-cty/cty"
+ "github.com/zclconf/go-cty/cty/function"
+
+ "github.com/go-task/task/v3/taskfile/ast"
+)
+
+type HCLEvaluator struct {
+ EvalCtx *hcl.EvalContext
+}
+
+func NewHCLEvaluator(vars *ast.Vars) *HCLEvaluator {
+ ctx := &hcl.EvalContext{
+ Variables: map[string]cty.Value{},
+ Functions: builtinFunctions(),
+ }
+ if vars != nil {
+ for k, v := range vars.All() {
+ if v.Value != nil {
+ ctx.Variables[k] = cty.StringVal(fmt.Sprint(v.Value))
+ }
+ }
+ }
+ return &HCLEvaluator{EvalCtx: ctx}
+}
+
+func builtinFunctions() map[string]function.Function {
+ return map[string]function.Function{
+ "upper": stringFunc(strings.ToUpper),
+ "lower": stringFunc(strings.ToLower),
+ "join": joinFunc(),
+ "split": splitFunc(),
+ "env": envFunc(),
+ }
+}
+
+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 (e *HCLEvaluator) SetVar(name, value string) {
+ if e.EvalCtx.Variables == nil {
+ e.EvalCtx.Variables = map[string]cty.Value{}
+ }
+ e.EvalCtx.Variables[name] = cty.StringVal(value)
+}
+
+func (e *HCLEvaluator) EvalString(expr hcl.Expression) (string, error) {
+ val, diags := expr.Value(e.EvalCtx)
+ if diags.HasErrors() {
+ return "", diags
+ }
+ switch {
+ case val.Type() == cty.String:
+ return val.AsString(), nil
+ case val.Type() == cty.Number:
+ bf := val.AsBigFloat()
+ return bf.Text('f', -1), nil
+ case val.Type() == cty.Bool:
+ if val.True() {
+ return "true", nil
+ }
+ return "false", nil
+ default:
+ return "", fmt.Errorf("unsupported value type %s", val.Type().FriendlyName())
+ }
+}
diff --git a/task.go b/task.go
index fc3f17662d..59a75b1977 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"
@@ -290,15 +291,24 @@ 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}
+ hclEval := hclext.NewHCLEvaluator(vars)
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/hcl_evaluator_test.go b/taskfile/hcl_evaluator_test.go
new file mode 100644
index 0000000000..dcd09865f1
--- /dev/null
+++ b/taskfile/hcl_evaluator_test.go
@@ -0,0 +1,37 @@
+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/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)
+
+ expr1, diags := hclsyntax.ParseTemplate([]byte("${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(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_integration_test.go b/taskfile/hcl_integration_test.go
deleted file mode 100644
index 0f42d99211..0000000000
--- a/taskfile/hcl_integration_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package taskfile
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestHCLTaskfileRun(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- data := []byte(`version = "3"
- task "hello" {
- cmds = ["echo hi"]
- }
-`)
- path := filepath.Join(dir, "Taskfile.hcl")
- require.NoError(t, os.WriteFile(path, data, 0o644))
-
- cmd := exec.Command("go", "run", "./cmd/task", "-t", path, "hello")
- err := cmd.Run()
- require.Error(t, err)
-}
diff --git a/taskfile/reader.go b/taskfile/reader.go
index 2970879749..f11d5522a1 100644
--- a/taskfile/reader.go
+++ b/taskfile/reader.go
@@ -373,16 +373,21 @@ 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{}
+ }
+ // Set the location of the taskfile for each task
+ if task.Location.Taskfile == "" {
+ task.Location.Taskfile = tf.Location
+ }
}
}
diff --git a/variables.go b/variables.go
index 261de59b7e..176c4aaad0 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"
)
@@ -46,6 +47,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
}
cache := &templater.Cache{Vars: vars}
+ hclEval := hclext.NewHCLEvaluator(vars)
new := ast.Task{
Task: origTask.Task,
@@ -173,9 +175,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 +197,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)
}
}
From e32ac8222a4b605badd1b0b1a78e5e05af26894d Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 09:30:25 -0500
Subject: [PATCH 06/11] fix: bug preventing simple run
---
taskfile/reader.go | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/taskfile/reader.go b/taskfile/reader.go
index f11d5522a1..3600a662c9 100644
--- a/taskfile/reader.go
+++ b/taskfile/reader.go
@@ -384,6 +384,14 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error)
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
From 2e4e259e62b5eba5deabac3e99758f1dd65efba0 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 10:05:04 -0500
Subject: [PATCH 07/11] docs: document HCL Taskfile support - task 6 (#7)
---
README.md | 4 +++
internal/flags/flags.go | 3 ++
website/docs/getting_started.mdx | 29 ++++++++++++++-----
website/docs/reference/cli.mdx | 8 +++--
website/docs/reference/package.mdx | 2 +-
website/docs/styleguide.mdx | 6 ++--
website/docs/usage.mdx | 20 ++++++++++---
.../version-latest/getting_started.mdx | 29 ++++++++++++++-----
.../version-latest/reference/cli.mdx | 7 +++--
.../version-latest/reference/package.mdx | 2 +-
.../version-latest/styleguide.mdx | 6 ++--
.../versioned_docs/version-latest/usage.mdx | 20 ++++++++++---
12 files changed, 100 insertions(+), 36 deletions(-)
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/internal/flags/flags.go b/internal/flags/flags.go
index 7dd9e43d15..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:
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
From 53c52b7d7f6cd0bca5be40dcac599f77e72848d3 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 10:24:10 -0500
Subject: [PATCH 08/11] feat: hcl native approaches - task 8 (#8)
---
compiler.go | 2 +-
executor_call_task.go | 21 +++++++
hcl_e2e_test.go | 35 +++++++++++
internal/hclext/evaluator.go | 104 +++++++++++++++++++++++++++----
task.go | 19 ++----
taskfile/hcl_evaluator_test.go | 7 ++-
taskfile/hcl_loader_test.go | 6 +-
taskfile/hcl_runtime_test.go | 59 ++++++++++++++++++
taskfile/loader_hcl.go | 92 +++++++++++++++++++++++----
testdata/HCLE2ETest/Taskfile.hcl | 29 +++++++++
variables.go | 79 +++++++++++++++++++----
11 files changed, 395 insertions(+), 58 deletions(-)
create mode 100644 executor_call_task.go
create mode 100644 hcl_e2e_test.go
create mode 100644 taskfile/hcl_runtime_test.go
create mode 100644 testdata/HCLE2ETest/Taskfile.hcl
diff --git a/compiler.go b/compiler.go
index 90b274f2c5..9c59636f80 100644
--- a/compiler.go
+++ b/compiler.go
@@ -47,7 +47,7 @@ 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)
+ evaluator := hclext.NewHCLEvaluator(result, result, nil)
specialVars, err := c.getSpecialVars(t, call)
if err != nil {
return nil, err
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/hcl_e2e_test.go b/hcl_e2e_test.go
new file mode 100644
index 0000000000..ef57639078
--- /dev/null
+++ b/hcl_e2e_test.go
@@ -0,0 +1,35 @@
+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, "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)
+ }
+}
diff --git a/internal/hclext/evaluator.go b/internal/hclext/evaluator.go
index 766601196d..3cdcb3f23b 100644
--- a/internal/hclext/evaluator.go
+++ b/internal/hclext/evaluator.go
@@ -3,6 +3,7 @@ package hclext
import (
"fmt"
"os"
+ "os/exec"
"strings"
"github.com/hashicorp/hcl/v2"
@@ -12,33 +13,56 @@ import (
"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 *ast.Vars) *HCLEvaluator {
- ctx := &hcl.EvalContext{
- Variables: map[string]cty.Value{},
- Functions: builtinFunctions(),
- }
+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 {
- ctx.Variables[k] = cty.StringVal(fmt.Sprint(v.Value))
+ varVals[k] = cty.StringVal(fmt.Sprint(v.Value))
+ }
+ }
+ }
+ envVals := map[string]cty.Value{}
+ if env != nil {
+ for k, v := range env.All() {
+ if v.Value != nil {
+ envVals[k] = cty.StringVal(fmt.Sprint(v.Value))
}
}
}
- return &HCLEvaluator{EvalCtx: ctx}
+ 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() map[string]function.Function {
- return map[string]function.Function{
+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"),
+ }
+ if runner != nil {
+ funcs["task"] = taskFunc(runner)
}
+ return funcs
}
func stringFunc(fn func(string) string) function.Function {
@@ -99,11 +123,67 @@ func envFunc() function.Function {
})
}
+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.EvalCtx.Variables == nil {
- e.EvalCtx.Variables = map[string]cty.Value{}
+ if e.vars == nil {
+ e.vars = map[string]cty.Value{}
}
- e.EvalCtx.Variables[name] = cty.StringVal(value)
+ e.vars[name] = cty.StringVal(value)
+ e.EvalCtx.Variables["vars"] = cty.ObjectVal(e.vars)
}
func (e *HCLEvaluator) EvalString(expr hcl.Expression) (string, error) {
diff --git a/task.go b/task.go
index 59a75b1977..81611bc8e6 100644
--- a/task.go
+++ b/task.go
@@ -260,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) {
@@ -291,7 +283,8 @@ 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}
- hclEval := hclext.NewHCLEvaluator(vars)
+ runtimeEnv := env.GetEnviron()
+ hclEval := hclext.NewHCLEvaluator(vars, runtimeEnv, e.callTask)
extra := map[string]any{}
if deferredExitCode != nil && *deferredExitCode > 0 {
diff --git a/taskfile/hcl_evaluator_test.go b/taskfile/hcl_evaluator_test.go
index dcd09865f1..05196b5ae4 100644
--- a/taskfile/hcl_evaluator_test.go
+++ b/taskfile/hcl_evaluator_test.go
@@ -7,6 +7,7 @@ import (
"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"
)
@@ -15,15 +16,15 @@ func TestHCLEvaluatorExpressions(t *testing.T) {
t.Setenv("HOME", "/home/test")
vars := ast.NewVars()
vars.Set("FOO", ast.Var{Value: "bar"})
- eval := hclext.NewHCLEvaluator(vars)
+ eval := hclext.NewHCLEvaluator(vars, env.GetEnviron(), nil)
- expr1, diags := hclsyntax.ParseTemplate([]byte("${FOO}"), "test.hcl", hcl.InitialPos)
+ 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(FOO)}"), "test.hcl", hcl.InitialPos)
+ 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)
diff --git a/taskfile/hcl_loader_test.go b/taskfile/hcl_loader_test.go
index 7c0bc10c11..b8142c132c 100644
--- a/taskfile/hcl_loader_test.go
+++ b/taskfile/hcl_loader_test.go
@@ -14,9 +14,9 @@ func TestHCLLoader(t *testing.T) {
data := []byte(`version = "3"
task "build" {
desc = "Build the project"
- cmds = ["echo hello ${USER}"]
- vars = { USER = "world" }
- env = { GREETING = "hi" }
+ vars { USER = "world" }
+ env { GREETING = "hi" }
+ cmds = ["echo hello ${vars.USER}"]
}
`)
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_hcl.go b/taskfile/loader_hcl.go
index 34f3957ad2..d51357ac38 100644
--- a/taskfile/loader_hcl.go
+++ b/taskfile/loader_hcl.go
@@ -29,10 +29,10 @@ func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
schema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "version", Required: true},
- {Name: "vars"},
- {Name: "env"},
},
Blocks: []hcl.BlockHeaderSchema{
+ {Type: "vars"},
+ {Type: "env"},
{Type: "task", LabelNames: []string{"name"}},
},
}
@@ -53,15 +53,15 @@ func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) {
tf := &ast.Taskfile{Version: version, Tasks: ast.NewTasks()}
- if attr, ok := content.Attributes["vars"]; ok {
- vars, err := parseVars(attr.Expr, location)
+ 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 attr, ok := content.Attributes["env"]; ok {
- env, err := parseVars(attr.Expr, location)
+ if blocks := content.Blocks.OfType("env"); len(blocks) > 0 {
+ env, err := parseVarsBlock(blocks[0], location)
if err != nil {
return nil, err
}
@@ -99,8 +99,11 @@ func parseTask(block *hcl.Block, location string) (*ast.Task, error) {
Attributes: []hcl.AttributeSchema{
{Name: "desc"},
{Name: "cmds"},
- {Name: "vars"},
- {Name: "env"},
+ {Name: "deps"},
+ },
+ Blocks: []hcl.BlockHeaderSchema{
+ {Type: "vars"},
+ {Type: "env"},
},
}
content, diags := block.Body.Content(schema)
@@ -127,15 +130,23 @@ func parseTask(block *hcl.Block, location string) (*ast.Task, error) {
}
}
- if attr, ok := content.Attributes["vars"]; ok {
- vars, err := parseVars(attr.Expr, location)
+ 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 attr, ok := content.Attributes["env"]; ok {
- env, err := parseVars(attr.Expr, location)
+ if blocks := content.Blocks.OfType("env"); len(blocks) > 0 {
+ env, err := parseVarsBlock(blocks[0], location)
if err != nil {
return nil, err
}
@@ -145,7 +156,7 @@ func parseTask(block *hcl.Block, location string) (*ast.Task, error) {
return t, nil
}
-func parseVars(expr hcl.Expression, location string) (*ast.Vars, error) {
+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{}}
@@ -175,6 +186,61 @@ func parseVars(expr hcl.Expression, location string) (*ast.Vars, error) {
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)
diff --git a/testdata/HCLE2ETest/Taskfile.hcl b/testdata/HCLE2ETest/Taskfile.hcl
new file mode 100644
index 0000000000..7689dade0e
--- /dev/null
+++ b/testdata/HCLE2ETest/Taskfile.hcl
@@ -0,0 +1,29 @@
+version = "3"
+
+vars {
+ ORIGINAL = "foo"
+}
+
+env {
+ 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 "all" {
+ deps = [
+ task("build"),
+ task("lint", {MODE = "fast"})
+ ]
+ cmds = [
+ "echo FINAL ${vars.ORIGINAL}",
+ "echo PATH=${env.PATH_COPY}"
+ ]
+}
diff --git a/variables.go b/variables.go
index 176c4aaad0..457033782e 100644
--- a/variables.go
+++ b/variables.go
@@ -47,7 +47,6 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
}
cache := &templater.Cache{Vars: vars}
- hclEval := hclext.NewHCLEvaluator(vars)
new := ast.Task{
Task: origTask.Task,
@@ -110,9 +109,40 @@ 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 {
+ evalTemp := hclext.NewHCLEvaluator(vars, env.GetEnviron(), e.callTask)
+ evaluated := ast.NewVars()
+ for k, v := range e.Taskfile.Env.All() {
+ if v.Expr != nil {
+ val, err := evalTemp.EvalString(v.Expr)
+ if err != nil {
+ return nil, err
+ }
+ evaluated.Set(k, ast.Var{Value: val})
+ } else {
+ evaluated.Set(k, v)
+ }
+ }
+ new.Env.Merge(evaluated, nil)
+ new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil)
+ evaluated = ast.NewVars()
+ for k, v := range origTask.Env.All() {
+ if v.Expr != nil {
+ val, err := evalTemp.EvalString(v.Expr)
+ if err != nil {
+ return nil, err
+ }
+ evaluated.Set(k, ast.Var{Value: val})
+ } else {
+ evaluated.Set(k, v)
+ }
+ }
+ new.Env.Merge(evaluated, nil)
+ } 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
@@ -128,6 +158,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
@@ -222,31 +256,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)
}
}
From c84f532f64619381ba1e5e8b8064377ce3844cf6 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 11:46:03 -0500
Subject: [PATCH 09/11] feat: add recursive HCL variable resolution - task 9
(#9)
---
compiler.go | 65 +++------
hcl_e2e_test.go | 6 +
internal/hclext/resolve.go | 211 ++++++++++++++++++++++++++++
taskfile/hcl_recursive_vars_test.go | 59 ++++++++
testdata/HCLE2ETest/Taskfile.hcl | 9 +-
variables.go | 36 ++---
6 files changed, 314 insertions(+), 72 deletions(-)
create mode 100644 internal/hclext/resolve.go
create mode 100644 taskfile/hcl_recursive_vars_test.go
diff --git a/compiler.go b/compiler.go
index 9c59636f80..5e9bdd3853 100644
--- a/compiler.go
+++ b/compiler.go
@@ -48,6 +48,7 @@ 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
@@ -60,62 +61,30 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
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 {
- if v.Expr != nil {
- val, err := evaluator.EvalString(v.Expr)
- if err != nil {
- return err
- }
- result.Set(k, ast.Var{Value: val})
- evaluator.SetVar(k, val)
- return nil
- }
- if v.ShExpr != nil {
- if !evaluateShVars {
- result.Set(k, ast.Var{Value: ""})
- evaluator.SetVar(k, "")
- return nil
- }
- cmd, err := evaluator.EvalString(v.ShExpr)
- if err != nil {
- return err
- }
- static, err := c.HandleDynamicVar(ast.Var{Sh: &cmd}, dir, env.GetFromVars(result))
- if err != nil {
- return err
- }
- result.Set(k, ast.Var{Value: static})
- evaluator.SetVar(k, static)
- return 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
@@ -163,19 +132,27 @@ 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 {
- 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
+ }
}
}
- for k, v := range t.Vars.All() {
- if err := taskRangeFunc(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)
}
return result, nil
diff --git a/hcl_e2e_test.go b/hcl_e2e_test.go
index ef57639078..e11aea7158 100644
--- a/hcl_e2e_test.go
+++ b/hcl_e2e_test.go
@@ -32,4 +32,10 @@ func TestHCLE2E(t *testing.T) {
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)
+ }
}
diff --git a/internal/hclext/resolve.go b/internal/hclext/resolve.go
new file mode 100644
index 0000000000..a86054235f
--- /dev/null
+++ b/internal/hclext/resolve.go
@@ -0,0 +1,211 @@
+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]string
+ envCache map[string]string
+ 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]string{},
+ envCache: map[string]string{},
+ 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] = fmt.Sprint(v.Value)
+ }
+ }
+ }
+ }
+ if env != nil {
+ for k, v := range env.All() {
+ if v.Expr == nil {
+ if v.Value != nil {
+ r.envCache[k] = fmt.Sprint(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) (string, error) {
+ if v, ok := r.varCache[name]; ok {
+ return v, nil
+ }
+ if r.varStack[name] {
+ return "", fmt.Errorf("cyclic variable reference for %s", name)
+ }
+ if r.vars == nil {
+ return "", fmt.Errorf("undefined variable %s", name)
+ }
+ v, ok := r.vars.Get(name)
+ if !ok {
+ return "", fmt.Errorf("undefined variable %s", name)
+ }
+ if v.Expr == nil {
+ val := fmt.Sprint(v.Value)
+ r.varCache[name] = val
+ return val, 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 "", err
+ }
+ }
+ for de := range depsEnv {
+ if _, err := r.resolveEnv(de); err != nil {
+ return "", err
+ }
+ }
+ eval := NewHCLEvaluator(varsFromCache(r.varCache), envFromCache(r.envCache), r.runner)
+ val, err := eval.EvalString(v.Expr)
+ if err != nil {
+ return "", err
+ }
+ r.varCache[name] = val
+ return val, nil
+}
+
+func (r *Resolver) resolveEnv(name string) (string, error) {
+ if v, ok := r.envCache[name]; ok {
+ return v, nil
+ }
+ if r.envStack[name] {
+ return "", fmt.Errorf("cyclic env reference for %s", name)
+ }
+ if r.env != nil {
+ if v, ok := r.env.Get(name); ok {
+ if v.Expr == nil {
+ val := fmt.Sprint(v.Value)
+ r.envCache[name] = val
+ return val, 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 "", err
+ }
+ }
+ for de := range depsEnv {
+ if _, err := r.resolveEnv(de); err != nil {
+ return "", err
+ }
+ }
+ eval := NewHCLEvaluator(varsFromCache(r.varCache), envFromCache(r.envCache), r.runner)
+ val, err := eval.EvalString(v.Expr)
+ if err != nil {
+ return "", err
+ }
+ r.envCache[name] = val
+ return val, nil
+ }
+ }
+ // Not defined; return empty string
+ r.envCache[name] = ""
+ return "", nil
+}
+
+func varsFromCache(m map[string]string) *ast.Vars {
+ vs := ast.NewVars()
+ for k, v := range m {
+ vs.Set(k, ast.Var{Value: v})
+ }
+ return vs
+}
+
+func envFromCache(m map[string]string) *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/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/testdata/HCLE2ETest/Taskfile.hcl b/testdata/HCLE2ETest/Taskfile.hcl
index 7689dade0e..8e14b25500 100644
--- a/testdata/HCLE2ETest/Taskfile.hcl
+++ b/testdata/HCLE2ETest/Taskfile.hcl
@@ -2,9 +2,14 @@ version = "3"
vars {
ORIGINAL = "foo"
+ NAME = "BOB"
+ GREETING = "Hello, ${vars.NAME}!"
+ UPPER_GREETING = upper(vars.GREETING)
}
env {
+ EXTENDED = "${env.BASE}-ext"
+ BASE = "base"
PATH_COPY = env("PATH")
}
@@ -24,6 +29,8 @@ task "all" {
]
cmds = [
"echo FINAL ${vars.ORIGINAL}",
- "echo PATH=${env.PATH_COPY}"
+ "echo PATH=${env.PATH_COPY}",
+ "echo GREET=${vars.UPPER_GREETING}",
+ "echo EXT=${env.EXTENDED}"
]
}
diff --git a/variables.go b/variables.go
index 457033782e..d9ac1ae56b 100644
--- a/variables.go
+++ b/variables.go
@@ -110,34 +110,16 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
new.Env = ast.NewVars()
if origTask.IsHCL {
- evalTemp := hclext.NewHCLEvaluator(vars, env.GetEnviron(), e.callTask)
- evaluated := ast.NewVars()
- for k, v := range e.Taskfile.Env.All() {
- if v.Expr != nil {
- val, err := evalTemp.EvalString(v.Expr)
- if err != nil {
- return nil, err
- }
- evaluated.Set(k, ast.Var{Value: val})
- } else {
- evaluated.Set(k, v)
- }
- }
- new.Env.Merge(evaluated, nil)
- new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil)
- evaluated = ast.NewVars()
- for k, v := range origTask.Env.All() {
- if v.Expr != nil {
- val, err := evalTemp.EvalString(v.Expr)
- if err != nil {
- return nil, err
- }
- evaluated.Set(k, ast.Var{Value: val})
- } else {
- evaluated.Set(k, v)
- }
+ 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.Merge(evaluated, nil)
+ new.Env = resolvedEnv
} else {
new.Env.Merge(templater.ReplaceVars(e.Taskfile.Env, cache), nil)
new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil)
From 503ece35ffeaa96da993abd3499bdf52f7328552 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 12:23:19 -0500
Subject: [PATCH 10/11] feat: diags, support array and map vars - task 10 (#10)
---
hcl_e2e_test.go | 3 +
internal/hclext/diagnostics_test.go | 26 ++++++
internal/hclext/evaluator.go | 133 +++++++++++++++++++++++++---
internal/hclext/resolve.go | 54 ++++++-----
testdata/HCLE2ETest/Taskfile.hcl | 10 ++-
5 files changed, 184 insertions(+), 42 deletions(-)
create mode 100644 internal/hclext/diagnostics_test.go
diff --git a/hcl_e2e_test.go b/hcl_e2e_test.go
index e11aea7158..a28f9161fe 100644
--- a/hcl_e2e_test.go
+++ b/hcl_e2e_test.go
@@ -38,4 +38,7 @@ func TestHCLE2E(t *testing.T) {
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/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
index 3cdcb3f23b..ae80c1534d 100644
--- a/internal/hclext/evaluator.go
+++ b/internal/hclext/evaluator.go
@@ -7,9 +7,11 @@ import (
"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"
)
@@ -26,7 +28,7 @@ func NewHCLEvaluator(vars, env *ast.Vars, runner TaskRunner) *HCLEvaluator {
if vars != nil {
for k, v := range vars.All() {
if v.Value != nil {
- varVals[k] = cty.StringVal(fmt.Sprint(v.Value))
+ varVals[k] = toCty(v.Value)
}
}
}
@@ -34,7 +36,7 @@ func NewHCLEvaluator(vars, env *ast.Vars, runner TaskRunner) *HCLEvaluator {
if env != nil {
for k, v := range env.All() {
if v.Value != nil {
- envVals[k] = cty.StringVal(fmt.Sprint(v.Value))
+ envVals[k] = toCty(v.Value)
}
}
}
@@ -58,6 +60,7 @@ func builtinFunctions(runner TaskRunner) map[string]function.Function {
"sh": shellFunc("/bin/sh"),
"bash": shellFunc("/bin/bash"),
"zsh": shellFunc("/bin/zsh"),
+ "tuple": tupleFunc(),
}
if runner != nil {
funcs["task"] = taskFunc(runner)
@@ -123,6 +126,98 @@ func envFunc() function.Function {
})
}
+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}},
@@ -186,23 +281,35 @@ func (e *HCLEvaluator) SetVar(name, value string) {
e.EvalCtx.Variables["vars"] = cty.ObjectVal(e.vars)
}
-func (e *HCLEvaluator) EvalString(expr hcl.Expression) (string, error) {
+func (e *HCLEvaluator) EvalValue(expr hcl.Expression) (any, error) {
val, diags := expr.Value(e.EvalCtx)
if diags.HasErrors() {
- return "", diags
+ return nil, diags
}
- switch {
- case val.Type() == cty.String:
- return val.AsString(), nil
- case val.Type() == cty.Number:
- bf := val.AsBigFloat()
- return bf.Text('f', -1), nil
- case val.Type() == cty.Bool:
- if val.True() {
+ 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:
- return "", fmt.Errorf("unsupported value type %s", val.Type().FriendlyName())
+ 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
index a86054235f..aeda81bcbc 100644
--- a/internal/hclext/resolve.go
+++ b/internal/hclext/resolve.go
@@ -15,8 +15,8 @@ type Resolver struct {
vars *ast.Vars
env *ast.Vars
runner TaskRunner
- varCache map[string]string
- envCache map[string]string
+ varCache map[string]any
+ envCache map[string]any
varStack map[string]bool
envStack map[string]bool
}
@@ -27,8 +27,8 @@ func NewResolver(vars, env *ast.Vars, runner TaskRunner) *Resolver {
vars: vars,
env: env,
runner: runner,
- varCache: map[string]string{},
- envCache: map[string]string{},
+ varCache: map[string]any{},
+ envCache: map[string]any{},
varStack: map[string]bool{},
envStack: map[string]bool{},
}
@@ -36,7 +36,7 @@ func NewResolver(vars, env *ast.Vars, runner TaskRunner) *Resolver {
for k, v := range vars.All() {
if v.Expr == nil {
if v.Value != nil {
- r.varCache[k] = fmt.Sprint(v.Value)
+ r.varCache[k] = v.Value
}
}
}
@@ -45,7 +45,7 @@ func NewResolver(vars, env *ast.Vars, runner TaskRunner) *Resolver {
for k, v := range env.All() {
if v.Expr == nil {
if v.Value != nil {
- r.envCache[k] = fmt.Sprint(v.Value)
+ r.envCache[k] = v.Value
}
}
}
@@ -84,78 +84,76 @@ func (r *Resolver) Resolve() (*ast.Vars, *ast.Vars, error) {
return vars, env, nil
}
-func (r *Resolver) resolveVar(name string) (string, error) {
+func (r *Resolver) resolveVar(name string) (any, error) {
if v, ok := r.varCache[name]; ok {
return v, nil
}
if r.varStack[name] {
- return "", fmt.Errorf("cyclic variable reference for %s", name)
+ return nil, fmt.Errorf("cyclic variable reference for %s", name)
}
if r.vars == nil {
- return "", fmt.Errorf("undefined variable %s", name)
+ return nil, fmt.Errorf("undefined variable %s", name)
}
v, ok := r.vars.Get(name)
if !ok {
- return "", fmt.Errorf("undefined variable %s", name)
+ return nil, fmt.Errorf("undefined variable %s", name)
}
if v.Expr == nil {
- val := fmt.Sprint(v.Value)
- r.varCache[name] = val
- return val, 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 "", err
+ return nil, err
}
}
for de := range depsEnv {
if _, err := r.resolveEnv(de); err != nil {
- return "", err
+ return nil, err
}
}
eval := NewHCLEvaluator(varsFromCache(r.varCache), envFromCache(r.envCache), r.runner)
- val, err := eval.EvalString(v.Expr)
+ val, err := eval.EvalValue(v.Expr)
if err != nil {
- return "", err
+ return nil, err
}
r.varCache[name] = val
return val, nil
}
-func (r *Resolver) resolveEnv(name string) (string, error) {
+func (r *Resolver) resolveEnv(name string) (any, error) {
if v, ok := r.envCache[name]; ok {
return v, nil
}
if r.envStack[name] {
- return "", fmt.Errorf("cyclic env reference for %s", 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 {
- val := fmt.Sprint(v.Value)
- r.envCache[name] = val
- return val, 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 "", err
+ return nil, err
}
}
for de := range depsEnv {
if _, err := r.resolveEnv(de); err != nil {
- return "", err
+ return nil, err
}
}
eval := NewHCLEvaluator(varsFromCache(r.varCache), envFromCache(r.envCache), r.runner)
- val, err := eval.EvalString(v.Expr)
+ val, err := eval.EvalValue(v.Expr)
if err != nil {
- return "", err
+ return nil, err
}
r.envCache[name] = val
return val, nil
@@ -166,7 +164,7 @@ func (r *Resolver) resolveEnv(name string) (string, error) {
return "", nil
}
-func varsFromCache(m map[string]string) *ast.Vars {
+func varsFromCache(m map[string]any) *ast.Vars {
vs := ast.NewVars()
for k, v := range m {
vs.Set(k, ast.Var{Value: v})
@@ -174,7 +172,7 @@ func varsFromCache(m map[string]string) *ast.Vars {
return vs
}
-func envFromCache(m map[string]string) *ast.Vars {
+func envFromCache(m map[string]any) *ast.Vars {
vs := ast.NewVars()
for k, v := range m {
vs.Set(k, ast.Var{Value: v})
diff --git a/testdata/HCLE2ETest/Taskfile.hcl b/testdata/HCLE2ETest/Taskfile.hcl
index 8e14b25500..82df18a965 100644
--- a/testdata/HCLE2ETest/Taskfile.hcl
+++ b/testdata/HCLE2ETest/Taskfile.hcl
@@ -5,6 +5,13 @@ vars {
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]
+ }
}
env {
@@ -31,6 +38,7 @@ task "all" {
"echo FINAL ${vars.ORIGINAL}",
"echo PATH=${env.PATH_COPY}",
"echo GREET=${vars.UPPER_GREETING}",
- "echo EXT=${env.EXTENDED}"
+ "echo EXT=${env.EXTENDED}",
+ "echo PLATFORM=${vars.SUPPORTED_PLATFORMS[0]}"
]
}
From e0398422400acc9894bc8e6defb31fdee6bf2706 Mon Sep 17 00:00:00 2001
From: Walter Scott
Date: Tue, 5 Aug 2025 13:03:12 -0500
Subject: [PATCH 11/11] feat: add task-scoped vars - task 11 (#11)
---
compiler.go | 28 ++++++++++++++++++++--------
hcl_e2e_test.go | 3 +++
testdata/HCLE2ETest/Taskfile.hcl | 12 +++++++++++-
3 files changed, 34 insertions(+), 9 deletions(-)
diff --git a/compiler.go b/compiler.go
index 5e9bdd3853..6430459387 100644
--- a/compiler.go
+++ b/compiler.go
@@ -132,6 +132,18 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
}
}
+ 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
+ }
+
if t != nil && call != nil {
for k, v := range call.Vars.All() {
if err := rangeFunc(k, v); err != nil {
@@ -143,16 +155,16 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return nil, err
}
}
- }
- if hasHCL {
- resVars, resEnv, err := hclext.NewResolver(result, result, nil).Resolve()
- if 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)
}
- result = ast.NewVars()
- result.Merge(resEnv, nil)
- result.Merge(resVars, nil)
}
return result, nil
diff --git a/hcl_e2e_test.go b/hcl_e2e_test.go
index a28f9161fe..5d1959c9f0 100644
--- a/hcl_e2e_test.go
+++ b/hcl_e2e_test.go
@@ -25,6 +25,9 @@ func TestHCLE2E(t *testing.T) {
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)
}
diff --git a/testdata/HCLE2ETest/Taskfile.hcl b/testdata/HCLE2ETest/Taskfile.hcl
index 82df18a965..6e62a983f6 100644
--- a/testdata/HCLE2ETest/Taskfile.hcl
+++ b/testdata/HCLE2ETest/Taskfile.hcl
@@ -12,6 +12,7 @@ vars {
cache = true
platform = vars.SUPPORTED_PLATFORMS[0]
}
+ ABC = 123
}
env {
@@ -29,10 +30,19 @@ 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("lint", {MODE = "fast"}),
+ task("scoped"),
]
cmds = [
"echo FINAL ${vars.ORIGINAL}",