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