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 <christopher.homberger@web.de>
Co-committed-by: ChristopherHX <christopher.homberger@web.de>
This commit is contained in:
ChristopherHX
2025-12-18 19:27:30 +00:00
committed by ChristopherHX
parent 83cbf1f2b8
commit ee2e0135d5
6 changed files with 164 additions and 14 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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{},
})
}

View File

@@ -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) {

View File

@@ -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

View File

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