From ee2e0135d50218a03b6b87f56a5b7d5236afe945 Mon Sep 17 00:00:00 2001 From: ChristopherHX Date: Thu, 18 Dec 2025 19:27:30 +0000 Subject: [PATCH] feat: implement case function (#16) Follow GitHub Actions * added unit tests Closes #15 Reviewed-on: https://gitea.com/actions-oss/act-cli/pulls/16 Co-authored-by: ChristopherHX Co-committed-by: ChristopherHX --- .gitea/workflows/checks.yml | 12 +++++ internal/eval/v2/evaluator_test.go | 6 +++ internal/eval/v2/functions.go | 26 +++++++++++ internal/templateeval/evaluate.go | 2 +- pkg/schema/schema.go | 57 +++++++++++++++++------ pkg/schema/schema_test.go | 75 ++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/checks.yml b/.gitea/workflows/checks.yml index 11a4d0b0..3db3cf38 100644 --- a/.gitea/workflows/checks.yml +++ b/.gitea/workflows/checks.yml @@ -43,6 +43,18 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 2 + - name: Cleanup Docker Engine + run: | + docker ps -a --format '{{ if eq (truncate .Names 4) "act-" }} + {{ .ID }} + {{end}}' | xargs -r docker rm -f || : + docker volume ls --format '{{ if eq (truncate .Name 4) "act-" }} + {{ .Name }} + {{ end }}' | xargs -r docker volume rm -f || : + docker images --format '{{ if eq (truncate .Repository 4) "act-" }} + {{ .ID }} + {{ end }}' | xargs -r docker rmi -f || : + docker images -q | xargs -r docker rmi || : - name: Set up QEMU if: '!env.NO_QEMU' uses: docker/setup-qemu-action@v3 diff --git a/internal/eval/v2/evaluator_test.go b/internal/eval/v2/evaluator_test.go index bcebc291..916cba3f 100644 --- a/internal/eval/v2/evaluator_test.go +++ b/internal/eval/v2/evaluator_test.go @@ -97,6 +97,12 @@ func TestEvaluator_Raw(t *testing.T) { {"contains(fromjson('[[3,5],[5,6]]').*[1], 6)", true}, {"contains(fromjson('[[3,5],[5,6]]').*[1], 3)", false}, {"contains(fromjson('[[3,5],[5,6]]').*[1], '6')", true}, + {"case(6 == 6, 0, 1)", 0.0}, + {"case(6 != 6, 0, 1)", 1.0}, + {"case(6 != 6, 0, 'test')", "test"}, + {"case(contains(fromjson('[\"ac\"]'), 'a'), 0, 'test')", "test"}, + {"case(0 == 1, 0, 2 == 2, 1, 0)", 1.0}, + {"case(0 == 1, 0, 2 != 2, 1, 0)", 0.0}, } for _, tt := range tests { diff --git a/internal/eval/v2/functions.go b/internal/eval/v2/functions.go index 5599491e..bf90f81a 100644 --- a/internal/eval/v2/functions.go +++ b/internal/eval/v2/functions.go @@ -2,6 +2,7 @@ package v2 import ( "encoding/json" + "errors" "strings" "github.com/actions-oss/act-cli/internal/eval/functions" @@ -163,6 +164,30 @@ func (Join) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult return CreateIntermediateResult(eval.Context(), ""), nil } +type Case struct { +} + +func (Case) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) { + if len(args)%2 == 0 { + return nil, errors.New("case function requires an odd number of arguments") + } + + for i := 0; i < len(args)-1; i += 2 { + condition, err := eval.Evaluate(args[i]) + if err != nil { + return nil, err + } + if condition.kind != ValueKindBoolean { + return nil, errors.New("case function conditions must evaluate to boolean") + } + if condition.IsTruthy() { + return eval.Evaluate(args[i+1]) + } + } + + return eval.Evaluate(args[len(args)-1]) +} + func GetFunctions() CaseInsensitiveObject[Function] { return CaseInsensitiveObject[Function](map[string]Function{ "fromjson": &FromJSON{}, @@ -172,5 +197,6 @@ func GetFunctions() CaseInsensitiveObject[Function] { "endswith": &EndsWith{}, "format": &Format{}, "join": &Join{}, + "case": &Case{}, }) } diff --git a/internal/templateeval/evaluate.go b/internal/templateeval/evaluate.go index e96b2211..885c5326 100644 --- a/internal/templateeval/evaluate.go +++ b/internal/templateeval/evaluate.go @@ -64,7 +64,7 @@ func (ee ExpressionEvaluator) canEvaluate(parsed exprparser.Node, snode *schema. canEvaluate = canEvaluate && ee.EvaluationContext.Variables.Get(v) != nil } for _, v := range snode.GetFunctions() { - canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(v.Name) != nil + canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(v.GetName()) != nil } exprparser.VisitNode(parsed, func(node exprparser.Node) { switch el := node.(type) { diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index efde10d6..c0a48d63 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -198,12 +198,44 @@ type Node struct { Context []string } -type FunctionInfo struct { +type FunctionInfo interface { + GetName() string + Check(args []exprparser.Node) error +} + +type BasicFunctionInfo struct { Name string Min int Max int } +func (f BasicFunctionInfo) GetName() string { + return f.Name +} + +func (f BasicFunctionInfo) Check(args []exprparser.Node) error { + var err error + if f.Min > len(args) { + err = errors.Join(err, fmt.Errorf("missing parameters for %s expected >= %v got %v", f.Name, f.Min, len(args))) + } + if f.Max < len(args) { + err = errors.Join(err, fmt.Errorf("too many parameters for %s expected <= %v got %v", f.Name, f.Max, len(args))) + } + return err +} + +type OddFunctionInfo struct { + BasicFunctionInfo +} + +func (f OddFunctionInfo) Check(args []exprparser.Node) error { + var err error + if len(args)%2 == 0 { + err = errors.Join(err, fmt.Errorf("expected odd number of parameters for %s got %v", f.Name, len(args))) + } + return errors.Join(err, f.BasicFunctionInfo.Check(args)) +} + func (s *Node) checkSingleExpression(exprNode exprparser.Node) error { if len(s.Context) == 0 { switch exprNode.(type) { @@ -220,13 +252,8 @@ func (s *Node) checkSingleExpression(exprNode exprparser.Node) error { exprparser.VisitNode(exprNode, func(node exprparser.Node) { if funcCallNode, ok := node.(*exprparser.FunctionNode); ok { for _, v := range funcs { - if strings.EqualFold(funcCallNode.Name, v.Name) { - if v.Min > len(funcCallNode.Args) { - err = errors.Join(err, fmt.Errorf("missing parameters for %s expected >= %v got %v", funcCallNode.Name, v.Min, len(funcCallNode.Args))) - } - if v.Max < len(funcCallNode.Args) { - err = errors.Join(err, fmt.Errorf("too many parameters for %s expected <= %v got %v", funcCallNode.Name, v.Max, len(funcCallNode.Args))) - } + if strings.EqualFold(funcCallNode.Name, v.GetName()) { + err = v.Check(funcCallNode.Args) return } } @@ -255,6 +282,13 @@ func (s *Node) GetFunctions() []FunctionInfo { AddFunction(&funcs, "startsWith", 2, 2) AddFunction(&funcs, "toJson", 1, 1) AddFunction(&funcs, "fromJson", 1, 1) + funcs = append(funcs, &OddFunctionInfo{ + BasicFunctionInfo: BasicFunctionInfo{ + Name: "case", + Min: 3, + Max: 255, + }, + }) for _, v := range s.Context { i := strings.Index(v, "(") if i == -1 { @@ -271,7 +305,7 @@ func (s *Node) GetFunctions() []FunctionInfo { } else { maxParameters, _ = strconv.ParseInt(maxParametersRaw, 10, 32) } - funcs = append(funcs, FunctionInfo{ + funcs = append(funcs, &BasicFunctionInfo{ Name: functionName, Min: int(minParameters), Max: int(maxParameters), @@ -330,7 +364,7 @@ func (s *Node) checkExpression(node *yaml.Node) (bool, error) { } func AddFunction(funcs *[]FunctionInfo, s string, i1, i2 int) { - *funcs = append(*funcs, FunctionInfo{ + *funcs = append(*funcs, &BasicFunctionInfo{ Name: s, Min: i1, Max: i2, @@ -451,9 +485,6 @@ func (s *Node) checkOneOf(def Definition, node *yaml.Node) error { } } } - if matched == 0 { - matched = math.MaxInt - } if matched <= invalidProps { if matched < invalidProps { // clear, we have better matching ones diff --git a/pkg/schema/schema_test.go b/pkg/schema/schema_test.go index 856da5ac..53dc8146 100644 --- a/pkg/schema/schema_test.go +++ b/pkg/schema/schema_test.go @@ -148,3 +148,78 @@ jobs: }).UnmarshalYAML(&node) assert.NoError(t, err) } + +func TestSchemaErrors(t *testing.T) { + table := []struct { + name string // test name + input string // workflow yaml input + err string // error message substring + }{ + { + name: "case even parameters is error", + input: ` +${{ 'on' }}: push +jobs: + job-with-condition: + runs-on: self-hosted + steps: + - run: echo ${{ case(1 == 1, 'zero', 2 == 2, 'one', 'two', '') }} +`, + err: "expected odd number of parameters for case got 6", + }, + { + name: "case odd parameters no error", + input: ` +${{ 'on' }}: push +jobs: + job-with-condition: + runs-on: self-hosted + steps: + - run: echo ${{ case(1 == 1, 'zero', 2 == 2, 'one', 'two') }} +`, + }, + { + name: "case 1 parameters error", + input: ` +${{ 'on' }}: push +jobs: + job-with-condition: + runs-on: self-hosted + steps: + - run: echo ${{ case(1 == 1) }} +`, + err: "missing parameters for case expected >= 3 got 1", + }, + { + name: "invalid expression in step uses", + input: ` +on: push +jobs: + job-with-condition: + runs-on: self-hosted + steps: + - uses: ${{ format('actions/checkout@v%s', 'v2') }} +`, + err: "Line: 7 Column 17: expressions are not allowed here", + }, + } + + for _, test := range table { + t.Run(test.name, func(t *testing.T) { + var node yaml.Node + err := yaml.Unmarshal([]byte(test.input), &node) + if !assert.NoError(t, err) { + return + } + err = (&Node{ + Definition: "workflow-root-strict", + Schema: GetWorkflowSchema(), + }).UnmarshalYAML(&node) + if test.err != "" { + assert.ErrorContains(t, err, test.err) + } else { + assert.NoError(t, err) + } + }) + } +}