Replace expressions engine (#133)

This commit is contained in:
ChristopherHX
2025-10-06 13:53:15 +02:00
committed by GitHub
parent 418c708bb0
commit 82dccc7820
40 changed files with 6876 additions and 1304 deletions

View File

@@ -0,0 +1,122 @@
package functions
import (
"fmt"
"strconv"
"strings"
)
// Format evaluates a format string with the supplied arguments.
// It behaves like the C# implementation in the repository
// it supports escaped braces and numeric argument indices.
// Format specifiers (e.g. :D) are recognised but currently ignored.
func Format(formatStr string, args ...interface{}) (string, error) {
var sb strings.Builder
i := 0
for i < len(formatStr) {
lbrace := strings.IndexByte(formatStr[i:], '{')
rbrace := strings.IndexByte(formatStr[i:], '}')
// left brace
if lbrace >= 0 && (rbrace < 0 || rbrace > lbrace) {
l := i + lbrace
sb.WriteString(formatStr[i:l])
// escaped left brace
if l+1 < len(formatStr) && formatStr[l+1] == '{' {
sb.WriteString(formatStr[l : l+1])
i = l + 2
continue
}
// normal placeholder
if rbrace > lbrace+1 {
// read index
idx, endIdx, ok := readArgIndex(formatStr, l+1)
if !ok {
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
// read optional format specifier
spec, r, ok := readFormatSpecifiers(formatStr, endIdx+1)
if !ok {
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
if idx >= len(args) {
return "", fmt.Errorf("argument index %d out of range", idx)
}
// append argument (format specifier is ignored here)
arg := args[idx]
sb.WriteString(fmt.Sprintf("%v", arg))
if spec != "" {
// placeholder for future specifier handling
_ = spec
}
i = r + 1
continue
}
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
// right brace
if rbrace >= 0 {
// escaped right brace
if i+rbrace+1 < len(formatStr) && formatStr[i+rbrace+1] == '}' {
sb.WriteString(formatStr[i : i+rbrace+1])
i += rbrace + 2
continue
}
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
// rest of string
sb.WriteString(formatStr[i:])
break
}
return sb.String(), nil
}
// readArgIndex parses a decimal number starting at pos.
// It returns the parsed value, the index of the last digit and true on success.
func readArgIndex(s string, pos int) (int, int, bool) {
start := pos
for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
pos++
}
if start == pos {
return 0, 0, false
}
idx, err := strconv.Atoi(s[start:pos])
if err != nil {
return 0, 0, false
}
return idx, pos - 1, true
}
// readFormatSpecifiers reads an optional format specifier block.
// It returns the specifier string, the index of the closing '}' and true on success.
func readFormatSpecifiers(s string, pos int) (string, int, bool) {
if pos >= len(s) {
return "", 0, false
}
if s[pos] == '}' {
return "", pos, true
}
if s[pos] != ':' {
return "", 0, false
}
pos++ // skip ':'
start := pos
for pos < len(s) {
if s[pos] == '}' {
return s[start:pos], pos, true
}
if s[pos] == '}' && pos+1 < len(s) && s[pos+1] == '}' {
// escaped '}'
pos += 2
continue
}
pos++
}
return "", 0, false
}

View File

@@ -0,0 +1,14 @@
package functions
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormat(t *testing.T) {
s, err := Format("Hello {0}, you have {1} new messages", "Alice", 5)
assert.NoError(t, err)
fmt.Println(s) // Hello Alice, you have 5 new messages
}

View File

@@ -0,0 +1,464 @@
package v2
import (
"fmt"
"math"
"strconv"
"strings"
)
// ValueKind represents the type of a value in the evaluation engine.
// The values mirror the C# ValueKind enum.
//
// Note: The names are kept identical to the C# implementation for easier mapping.
//
// The lexer is intentionally simple it only tokenises the subset of
// expressions that are used in GitHub Actions workflow `if:` expressions.
// It does not evaluate the expression that is left to the parser.
type ValueKind int
const (
ValueKindNull ValueKind = iota
ValueKindBoolean
ValueKindNumber
ValueKindString
ValueKindObject
ValueKindArray
)
type ReadOnlyArray[T any] interface {
GetAt(i int64) T
GetEnumerator() []T
}
type ReadOnlyObject[T any] interface {
Get(key string) T
GetEnumerator() map[string]T
}
type BasicArray[T any] []T
func (a BasicArray[T]) GetAt(i int64) T {
if int(i) >= len(a) {
var zero T
return zero
}
return a[i]
}
func (a BasicArray[T]) GetEnumerator() []T {
return a
}
type CaseInsensitiveObject[T any] map[string]T
func (o CaseInsensitiveObject[T]) Get(key string) T {
for k, v := range o {
if strings.EqualFold(k, key) {
return v
}
}
var zero T
return zero
}
func (o CaseInsensitiveObject[T]) GetEnumerator() map[string]T {
return o
}
type CaseSensitiveObject[T any] map[string]T
func (o CaseSensitiveObject[T]) Get(key string) T {
return o[key]
}
func (o CaseSensitiveObject[T]) GetEnumerator() map[string]T {
return o
}
// EvaluationResult holds the result of evaluating an expression node.
// It mirrors the C# EvaluationResult class.
type EvaluationResult struct {
context *EvaluationContext
level int
value interface{}
kind ValueKind
raw interface{}
omitTracing bool
}
// NewEvaluationResult creates a new EvaluationResult.
func NewEvaluationResult(context *EvaluationContext, level int, val interface{}, kind ValueKind, raw interface{}, omitTracing bool) *EvaluationResult {
er := &EvaluationResult{context: context, level: level, value: val, kind: kind, raw: raw, omitTracing: omitTracing}
if !omitTracing {
er.traceValue()
}
return er
}
// Kind returns the ValueKind of the result.
func (er *EvaluationResult) Kind() ValueKind { return er.kind }
// Raw returns the raw value that was passed to the constructor.
func (er *EvaluationResult) Raw() interface{} { return er.raw }
// Value returns the canonical value.
func (er *EvaluationResult) Value() interface{} { return er.value }
// IsFalsy implements the logic from the C# class.
func (er *EvaluationResult) IsFalsy() bool {
switch er.kind {
case ValueKindNull:
return true
case ValueKindBoolean:
return !er.value.(bool)
case ValueKindNumber:
v := er.value.(float64)
return v == 0 || isNaN(v)
case ValueKindString:
return er.value.(string) == ""
default:
return false
}
}
func isNaN(v float64) bool { return v != v }
// IsPrimitive returns true if the kind is a primitive type.
func (er *EvaluationResult) IsPrimitive() bool { return er.kind <= ValueKindString }
// IsTruthy is the negation of IsFalsy.
func (er *EvaluationResult) IsTruthy() bool { return !er.IsFalsy() }
// AbstractEqual compares two EvaluationResults using the abstract equality algorithm.
func (er *EvaluationResult) AbstractEqual(other *EvaluationResult) bool {
return abstractEqual(er.value, other.value)
}
// AbstractGreaterThan compares two EvaluationResults.
func (er *EvaluationResult) AbstractGreaterThan(other *EvaluationResult) bool {
return abstractGreaterThan(er.value, other.value)
}
// AbstractGreaterThanOrEqual
func (er *EvaluationResult) AbstractGreaterThanOrEqual(other *EvaluationResult) bool {
return er.AbstractEqual(other) || er.AbstractGreaterThan(other)
}
// AbstractLessThan
func (er *EvaluationResult) AbstractLessThan(other *EvaluationResult) bool {
return abstractLessThan(er.value, other.value)
}
// AbstractLessThanOrEqual
func (er *EvaluationResult) AbstractLessThanOrEqual(other *EvaluationResult) bool {
return er.AbstractEqual(other) || er.AbstractLessThan(other)
}
// AbstractNotEqual
func (er *EvaluationResult) AbstractNotEqual(other *EvaluationResult) bool {
return !er.AbstractEqual(other)
}
// ConvertToNumber converts the value to a float64.
func (er *EvaluationResult) ConvertToNumber() float64 { return convertToNumber(er.value) }
// ConvertToString converts the value to a string.
func (er *EvaluationResult) ConvertToString() string {
switch er.kind {
case ValueKindNull:
return ""
case ValueKindBoolean:
if er.value.(bool) {
return ExpressionConstants.True
}
return ExpressionConstants.False
case ValueKindNumber:
return fmt.Sprintf(ExpressionConstants.NumberFormat, er.value.(float64))
case ValueKindString:
return er.value.(string)
default:
return fmt.Sprintf("%v", er.value)
}
}
// TryGetCollectionInterface returns the underlying collection if the value is an array or object.
func (er *EvaluationResult) TryGetCollectionInterface() (interface{}, bool) {
switch v := er.value.(type) {
case ReadOnlyArray[any]:
return v, true
case ReadOnlyObject[any]:
return v, true
default:
return nil, false
}
}
// CreateIntermediateResult creates an EvaluationResult from an arbitrary object.
func CreateIntermediateResult(context *EvaluationContext, obj interface{}) *EvaluationResult {
val, kind, raw := convertToCanonicalValue(obj)
return NewEvaluationResult(context, 0, val, kind, raw, true)
}
// --- Helper functions and constants ---------------------------------------
// ExpressionConstants holds string constants used in conversions.
var ExpressionConstants = struct {
True string
False string
NumberFormat string
}{
True: "true",
False: "false",
NumberFormat: "%.15g",
}
// convertToCanonicalValue converts an arbitrary Go value to a canonical form.
func convertToCanonicalValue(obj interface{}) (interface{}, ValueKind, interface{}) {
switch v := obj.(type) {
case nil:
return nil, ValueKindNull, nil
case bool:
return v, ValueKindBoolean, v
case int, int8, int16, int32, int64:
f := float64(toInt64(v))
return f, ValueKindNumber, f
case uint, uint8, uint16, uint32, uint64:
f := float64(toUint64(v))
return f, ValueKindNumber, f
case float32, float64:
f := toFloat64(v)
return f, ValueKindNumber, f
case string:
return v, ValueKindString, v
case []interface{}:
return BasicArray[any](v), ValueKindArray, v
case ReadOnlyArray[any]:
return v, ValueKindArray, v
case map[string]interface{}:
return CaseInsensitiveObject[any](v), ValueKindObject, v
case ReadOnlyObject[any]:
return v, ValueKindObject, v
default:
// Fallback: treat as object
return v, ValueKindObject, v
}
}
func toInt64(v interface{}) int64 {
switch i := v.(type) {
case int:
return int64(i)
case int8:
return int64(i)
case int16:
return int64(i)
case int32:
return int64(i)
case int64:
return i
default:
return 0
}
}
func toUint64(v interface{}) uint64 {
switch i := v.(type) {
case uint:
return uint64(i)
case uint8:
return uint64(i)
case uint16:
return uint64(i)
case uint32:
return uint64(i)
case uint64:
return i
default:
return 0
}
}
func toFloat64(v interface{}) float64 {
switch f := v.(type) {
case float32:
return float64(f)
case float64:
return f
default:
return 0
}
}
// coerceTypes implements the C# CoerceTypes logic.
// It converts values to compatible types before comparison.
func coerceTypes(left, right interface{}) (interface{}, interface{}, ValueKind, ValueKind) {
leftKind := getKind(left)
rightKind := getKind(right)
// same kind nothing to do
if leftKind == rightKind {
return left, right, leftKind, rightKind
}
// Number <-> String
if leftKind == ValueKindNumber && rightKind == ValueKindString {
right = convertToNumber(right)
rightKind = ValueKindNumber
return left, right, leftKind, rightKind
}
if leftKind == ValueKindString && rightKind == ValueKindNumber {
left = convertToNumber(left)
leftKind = ValueKindNumber
return left, right, leftKind, rightKind
}
// Boolean or Null -> Number
if leftKind == ValueKindBoolean || leftKind == ValueKindNull {
left = convertToNumber(left)
return coerceTypes(left, right)
}
if rightKind == ValueKindBoolean || rightKind == ValueKindNull {
right = convertToNumber(right)
return coerceTypes(left, right)
}
// otherwise keep as is
return left, right, leftKind, rightKind
}
// abstractEqual uses coerceTypes before comparing.
func abstractEqual(left, right interface{}) bool {
left, right, leftKind, rightKind := coerceTypes(left, right)
if leftKind != rightKind {
return false
}
switch leftKind {
case ValueKindNull:
return true
case ValueKindNumber:
l := left.(float64)
r := right.(float64)
if isNaN(l) || isNaN(r) {
return false
}
return l == r
case ValueKindString:
return strings.EqualFold(left.(string), right.(string))
case ValueKindBoolean:
return left.(bool) == right.(bool)
// Compare object equality fails via panic
// case ValueKindObject, ValueKindArray:
// return left == right
}
return false
}
// abstractGreaterThan uses coerceTypes before comparing.
func abstractGreaterThan(left, right interface{}) bool {
left, right, leftKind, rightKind := coerceTypes(left, right)
if leftKind != rightKind {
return false
}
switch leftKind {
case ValueKindNumber:
l := left.(float64)
r := right.(float64)
if isNaN(l) || isNaN(r) {
return false
}
return l > r
case ValueKindString:
return strings.Compare(left.(string), right.(string)) > 0
case ValueKindBoolean:
return left.(bool) && !right.(bool)
}
return false
}
// abstractLessThan uses coerceTypes before comparing.
func abstractLessThan(left, right interface{}) bool {
left, right, leftKind, rightKind := coerceTypes(left, right)
if leftKind != rightKind {
return false
}
switch leftKind {
case ValueKindNumber:
l := left.(float64)
r := right.(float64)
if isNaN(l) || isNaN(r) {
return false
}
return l < r
case ValueKindString:
return strings.Compare(left.(string), right.(string)) < 0
case ValueKindBoolean:
return !left.(bool) && right.(bool)
}
return false
}
// convertToNumber converts a value to a float64 following JavaScript rules.
func convertToNumber(v interface{}) float64 {
switch val := v.(type) {
case nil:
return 0
case bool:
if val {
return 1
}
return 0
case float64:
return val
case float32:
return float64(val)
case string:
// parsenumber
if val == "" {
return float64(0)
}
if len(val) > 2 {
switch val[:2] {
case "0x", "0o":
if i, err := strconv.ParseInt(val, 0, 32); err == nil {
return float64(i)
}
}
}
if f, err := strconv.ParseFloat(val, 64); err == nil {
return f
}
return math.NaN()
default:
return math.NaN()
}
}
// getKind returns the ValueKind for a Go value.
func getKind(v interface{}) ValueKind {
switch v.(type) {
case nil:
return ValueKindNull
case bool:
return ValueKindBoolean
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return ValueKindNumber
case string:
return ValueKindString
case []interface{}:
return ValueKindArray
case map[string]interface{}:
return ValueKindObject
default:
return ValueKindObject
}
}
// traceValue is a placeholder for tracing logic.
func (er *EvaluationResult) traceValue() {
// No-op in this simplified implementation.
}
// --- End of file ---------------------------------------

View File

@@ -0,0 +1,276 @@
package v2
import (
"errors"
"fmt"
exprparser "github.com/actions-oss/act-cli/internal/expr"
)
// EvaluationContext holds variables that can be referenced in expressions.
type EvaluationContext struct {
Variables ReadOnlyObject[any]
Functions ReadOnlyObject[Function]
}
func NewEvaluationContext() *EvaluationContext {
return &EvaluationContext{}
}
type Function interface {
Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error)
}
// Evaluator evaluates workflow expressions using the lexer and parser from workflow.
type Evaluator struct {
ctx *EvaluationContext
}
// NewEvaluator creates an Evaluator with the supplied context.
func NewEvaluator(ctx *EvaluationContext) *Evaluator {
return &Evaluator{ctx: ctx}
}
func (e *Evaluator) Context() *EvaluationContext {
return e.ctx
}
func (e *Evaluator) Evaluate(root exprparser.Node) (*EvaluationResult, error) {
result, err := e.evalNode(root)
if err != nil {
return nil, err
}
return result, nil
}
// EvaluateBoolean parses and evaluates the expression, returning a boolean result.
func (e *Evaluator) EvaluateBoolean(expr string) (bool, error) {
root, err := exprparser.Parse(expr)
if err != nil {
return false, fmt.Errorf("parse error: %w", err)
}
result, err := e.evalNode(root)
if err != nil {
return false, err
}
return result.IsTruthy(), nil
}
func (e *Evaluator) ToRaw(result *EvaluationResult) (interface{}, error) {
if col, ok := result.TryGetCollectionInterface(); ok {
switch node := col.(type) {
case ReadOnlyObject[any]:
rawMap := map[string]interface{}{}
for k, v := range node.GetEnumerator() {
rawRes, err := e.ToRaw(CreateIntermediateResult(e.Context(), v))
if err != nil {
return nil, err
}
rawMap[k] = rawRes
}
return rawMap, nil
case ReadOnlyArray[any]:
rawArray := []interface{}{}
for _, v := range node.GetEnumerator() {
rawRes, err := e.ToRaw(CreateIntermediateResult(e.Context(), v))
if err != nil {
return nil, err
}
rawArray = append(rawArray, rawRes)
}
return rawArray, nil
}
}
return result.Value(), nil
}
// Evaluate parses and evaluates the expression, returning a boolean result.
func (e *Evaluator) EvaluateRaw(expr string) (interface{}, error) {
root, err := exprparser.Parse(expr)
if err != nil {
return false, fmt.Errorf("parse error: %w", err)
}
result, err := e.evalNode(root)
if err != nil {
return false, err
}
return e.ToRaw(result)
}
type FilteredArray []interface{}
func (a FilteredArray) GetAt(i int64) interface{} {
if int(i) > len(a) {
return nil
}
return a[i]
}
func (a FilteredArray) GetEnumerator() []interface{} {
return a
}
// evalNode recursively evaluates a parser node and returns an EvaluationResult.
func (e *Evaluator) evalNode(n exprparser.Node) (*EvaluationResult, error) {
switch node := n.(type) {
case *exprparser.ValueNode:
return e.evalValueNode(node)
case *exprparser.FunctionNode:
return e.evalFunctionNode(node)
case *exprparser.BinaryNode:
return e.evalBinaryNode(node)
case *exprparser.UnaryNode:
return e.evalUnaryNode(node)
}
return nil, errors.New("unknown node type")
}
func (e *Evaluator) evalValueNode(node *exprparser.ValueNode) (*EvaluationResult, error) {
if node.Kind == exprparser.TokenKindNamedValue {
if e.ctx != nil {
val := e.ctx.Variables.Get(node.Value.(string))
if val == nil {
return nil, fmt.Errorf("undefined variable %s", node.Value)
}
return CreateIntermediateResult(e.Context(), val), nil
}
return nil, errors.New("no evaluation context")
}
return CreateIntermediateResult(e.Context(), node.Value), nil
}
func (e *Evaluator) evalFunctionNode(node *exprparser.FunctionNode) (*EvaluationResult, error) {
fn := e.ctx.Functions.Get(node.Name)
if fn == nil {
return nil, fmt.Errorf("unknown function %v", node.Name)
}
return fn.Evaluate(e, node.Args)
}
func (e *Evaluator) evalBinaryNode(node *exprparser.BinaryNode) (*EvaluationResult, error) {
left, err := e.evalNode(node.Left)
if err != nil {
return nil, err
}
if res, err := e.evalBinaryNodeLeft(node, left); res != nil || err != nil {
return res, err
}
right, err := e.evalNode(node.Right)
if err != nil {
return nil, err
}
return e.evalBinaryNodeRight(node, left, right)
}
func (e *Evaluator) evalBinaryNodeLeft(node *exprparser.BinaryNode, left *EvaluationResult) (*EvaluationResult, error) {
switch node.Op {
case "&&":
if left.IsFalsy() {
return left, nil
}
case "||":
if left.IsTruthy() {
return left, nil
}
case ".":
if v, ok := node.Right.(*exprparser.ValueNode); ok && v.Kind == exprparser.TokenKindWildcard {
var ret FilteredArray
if col, ok := left.TryGetCollectionInterface(); ok {
if farray, ok := col.(FilteredArray); ok {
for _, subcol := range farray.GetEnumerator() {
ret = processStar(CreateIntermediateResult(e.Context(), subcol).Value(), ret)
}
} else {
ret = processStar(col, ret)
}
}
return CreateIntermediateResult(e.Context(), ret), nil
}
}
return nil, nil
}
func (e *Evaluator) evalBinaryNodeRight(node *exprparser.BinaryNode, left *EvaluationResult, right *EvaluationResult) (*EvaluationResult, error) {
switch node.Op {
case "&&":
return right, nil
case "||":
return right, nil
case "==":
// Use abstract equality per spec
return CreateIntermediateResult(e.Context(), left.AbstractEqual(right)), nil
case "!=":
return CreateIntermediateResult(e.Context(), left.AbstractNotEqual(right)), nil
case ">":
return CreateIntermediateResult(e.Context(), left.AbstractGreaterThan(right)), nil
case "<":
return CreateIntermediateResult(e.Context(), left.AbstractLessThan(right)), nil
case ">=":
return CreateIntermediateResult(e.Context(), left.AbstractGreaterThanOrEqual(right)), nil
case "<=":
return CreateIntermediateResult(e.Context(), left.AbstractLessThanOrEqual(right)), nil
case ".", "[":
if farray, ok := left.Value().(FilteredArray); ok {
var ret FilteredArray
for _, subcol := range farray.GetEnumerator() {
res := processIndex(CreateIntermediateResult(e.Context(), subcol).Value(), right)
if res != nil {
ret = append(ret, res)
}
}
if ret == nil {
return CreateIntermediateResult(e.Context(), nil), nil
}
return CreateIntermediateResult(e.Context(), ret), nil
}
col, _ := left.TryGetCollectionInterface()
result := processIndex(col, right)
return CreateIntermediateResult(e.Context(), result), nil
default:
return nil, fmt.Errorf("unsupported operator %s", node.Op)
}
}
func (e *Evaluator) evalUnaryNode(node *exprparser.UnaryNode) (*EvaluationResult, error) {
operand, err := e.evalNode(node.Operand)
if err != nil {
return nil, err
}
switch node.Op {
case "!":
return CreateIntermediateResult(e.Context(), !operand.IsTruthy()), nil
default:
return nil, fmt.Errorf("unsupported unary operator %s", node.Op)
}
}
func processIndex(col interface{}, right *EvaluationResult) interface{} {
if mapVal, ok := col.(ReadOnlyObject[any]); ok {
key, ok := right.Value().(string)
if !ok {
return nil
}
val := mapVal.Get(key)
return val
}
if arrayVal, ok := col.(ReadOnlyArray[any]); ok {
key, ok := right.Value().(float64)
if !ok || key < 0 {
return nil
}
val := arrayVal.GetAt(int64(key))
return val
}
return nil
}
func processStar(subcol interface{}, ret FilteredArray) FilteredArray {
if array, ok := subcol.(ReadOnlyArray[any]); ok {
ret = append(ret, array.GetEnumerator()...)
} else if obj, ok := subcol.(ReadOnlyObject[any]); ok {
for _, v := range obj.GetEnumerator() {
ret = append(ret, v)
}
}
return ret
}

View File

@@ -0,0 +1,111 @@
package v2
import (
"testing"
)
// Test boolean and comparison operations using the evaluator.
func TestEvaluator_BooleanOps(t *testing.T) {
ctx := &EvaluationContext{Variables: CaseInsensitiveObject[any](map[string]interface{}{"a": 5, "b": 3})}
eval := NewEvaluator(ctx)
tests := []struct {
expr string
want bool
}{
{"1 == 1", true},
{"1 != 2", true},
{"5 > 3", true},
{"2 < 4", true},
{"5 >= 5", true},
{"3 <= 4", true},
{"true && false", false},
{"!false", true},
{"a > b", true},
}
for _, tt := range tests {
got, err := eval.EvaluateBoolean(tt.expr)
if err != nil {
t.Fatalf("evaluate %s error: %v", tt.expr, err)
}
if got != tt.want {
t.Fatalf("evaluate %s expected %v got %v", tt.expr, tt.want, got)
}
}
}
func TestEvaluator_Raw(t *testing.T) {
ctx := &EvaluationContext{
Variables: CaseInsensitiveObject[any](map[string]any{"a": 5, "b": 3}),
Functions: GetFunctions(),
}
eval := NewEvaluator(ctx)
tests := []struct {
expr string
want interface{}
}{
{"a.b['x']", nil},
{"(a.b).c['x']", nil},
{"(a.b).*['x']", nil},
{"(a['x'])", nil},
{"true || false", true},
{"false || false", false},
{"false || true", true},
{"false || true || false", true},
{"contains('', '') || contains('', '') || contains('', '')", true},
{"1 == 1", true},
{"1 != 2", true},
{"5 > 3", true},
{"2 < 4", true},
{"5 >= 5", true},
{"3 <= 4", true},
{"true && false", false},
{"!false", true},
{"a > b", true},
{"!(a > b)", false},
{"!(a > b) || !0", true},
{"!(a > b) || !(1)", false},
{"'Hello World'", "Hello World"},
{"23.5", 23.5},
{"fromjson('{\"twst\":\"x\"}')['twst']", "x"},
{"fromjson('{\"Twst\":\"x\"}')['twst']", "x"},
{"fromjson('{\"TwsT\":\"x\"}')['twst']", "x"},
{"fromjson('{\"TwsT\":\"x\"}')['tWst']", "x"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}').TwsT.a", "y"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}')['TwsT'].a", "y"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}')['TwsT']['a']", "y"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}').TwsT['a']", "y"},
// {"fromjson('{\"TwsT\":\"x\"}').*[0]", "x"},
{"fromjson('{\"TwsT\":[\"x\"]}')['TwsT'][0]", "x"},
{"fromjson('[]')['tWst']", nil},
{"fromjson('[]').tWst", nil},
{"contains('a', 'a')", true},
{"contains('bab', 'a')", true},
{"contains('bab', 'ac')", false},
{"contains(fromjson('[\"ac\"]'), 'ac')", true},
{"contains(fromjson('[\"ac\"]'), 'a')", false},
// {"fromjson('{\"TwsT\":{\"a\":\"y\"}}').*['a']", "y"},
{"fromjson(tojson(fromjson('{\"TwsT\":{\"a\":\"y\"}}').*.a))[0]", "y"},
{"fromjson(tojson(fromjson('{\"TwsT\":{\"a\":\"y\"}}').*['a']))[0]", "y"},
{"fromjson('{}').x", nil},
{"format('{0}', fromjson('{}').x)", ""},
{"format('{0}', fromjson('{}')[0])", ""},
{"fromjson(tojson(fromjson('[[3,5],[5,6]]').*[1]))[1]", float64(6)},
{"contains(fromjson('[[3,5],[5,6]]').*[1], 5)", true},
{"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},
}
for _, tt := range tests {
got, err := eval.EvaluateRaw(tt.expr)
if err != nil {
t.Fatalf("evaluate %s error: %v", tt.expr, err)
}
if got != tt.want {
t.Fatalf("evaluate %s expected %v got %v", tt.expr, tt.want, got)
}
}
}

View File

@@ -0,0 +1,176 @@
package v2
import (
"encoding/json"
"strings"
"github.com/actions-oss/act-cli/internal/eval/functions"
exprparser "github.com/actions-oss/act-cli/internal/expr"
)
type FromJSON struct {
}
func (FromJSON) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
r, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
var res any
if err := json.Unmarshal([]byte(r.ConvertToString()), &res); err != nil {
return nil, err
}
return CreateIntermediateResult(eval.Context(), res), nil
}
type ToJSON struct {
}
func (ToJSON) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
r, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
raw, err := eval.ToRaw(r)
if err != nil {
return nil, err
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return nil, err
}
return CreateIntermediateResult(eval.Context(), string(data)), nil
}
type Contains struct {
}
func (Contains) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
el, err := eval.Evaluate(args[1])
if err != nil {
return nil, err
}
// Array
if col, ok := collection.TryGetCollectionInterface(); ok {
if node, ok := col.(ReadOnlyArray[any]); ok {
for _, v := range node.GetEnumerator() {
canon := CreateIntermediateResult(eval.Context(), v)
if canon.AbstractEqual(el) {
return CreateIntermediateResult(eval.Context(), true), nil
}
}
}
return CreateIntermediateResult(eval.Context(), false), nil
}
// String
return CreateIntermediateResult(eval.Context(), strings.Contains(strings.ToLower(collection.ConvertToString()), strings.ToLower(el.ConvertToString()))), nil
}
type StartsWith struct {
}
func (StartsWith) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
el, err := eval.Evaluate(args[1])
if err != nil {
return nil, err
}
// String
return CreateIntermediateResult(eval.Context(), strings.HasPrefix(strings.ToLower(collection.ConvertToString()), strings.ToLower(el.ConvertToString()))), nil
}
type EndsWith struct {
}
func (EndsWith) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
el, err := eval.Evaluate(args[1])
if err != nil {
return nil, err
}
// String
return CreateIntermediateResult(eval.Context(), strings.HasSuffix(strings.ToLower(collection.ConvertToString()), strings.ToLower(el.ConvertToString()))), nil
}
type Format struct {
}
func (Format) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
sargs := []interface{}{}
for _, arg := range args[1:] {
el, err := eval.Evaluate(arg)
if err != nil {
return nil, err
}
sargs = append(sargs, el.ConvertToString())
}
ret, err := functions.Format(collection.ConvertToString(), sargs...)
return CreateIntermediateResult(eval.Context(), ret), err
}
type Join struct {
}
func (Join) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
var el *EvaluationResult
if len(args) > 1 {
if el, err = eval.Evaluate(args[1]); err != nil {
return nil, err
}
}
// Array
if col, ok := collection.TryGetCollectionInterface(); ok {
var elements []string
if node, ok := col.(ReadOnlyArray[any]); ok {
for _, v := range node.GetEnumerator() {
elements = append(elements, CreateIntermediateResult(eval.Context(), v).ConvertToString())
}
}
var sep string
if el != nil {
sep = el.ConvertToString()
} else {
sep = ","
}
return CreateIntermediateResult(eval.Context(), strings.Join(elements, sep)), nil
}
// Primitive
if collection.IsPrimitive() {
return CreateIntermediateResult(eval.Context(), collection.ConvertToString()), nil
}
return CreateIntermediateResult(eval.Context(), ""), nil
}
func GetFunctions() CaseInsensitiveObject[Function] {
return CaseInsensitiveObject[Function](map[string]Function{
"fromjson": &FromJSON{},
"tojson": &ToJSON{},
"contains": &Contains{},
"startswith": &StartsWith{},
"endswith": &EndsWith{},
"format": &Format{},
"join": &Join{},
})
}

View File

@@ -0,0 +1,27 @@
package workflow
import "testing"
func TestExpressionParser(t *testing.T) {
node, err := Parse("github.event_name")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("Parsed expression: %+v", node)
}
func TestExpressionParserWildcard(t *testing.T) {
node, err := Parse("github.commits.*.message")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("Parsed expression: %+v", node)
}
func TestExpressionParserDot(t *testing.T) {
node, err := Parse("github.head_commit.message")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("Parsed expression: %+v", node)
}

View File

@@ -0,0 +1,306 @@
package workflow
import (
"errors"
"fmt"
"strings"
)
// Node represents a node in the expression tree.
// It is intentionally minimal only the fields needed for the parser.
// Users can extend it with more information if required.
type Node interface {
String() string
}
// ValueNode represents a literal value (number, string, boolean, null) or a named value.
// The Kind field indicates the type.
// For named values the Value is nil.
type ValueNode struct {
Kind TokenKind
Value interface{}
}
// FunctionNode represents a function call with arguments.
type FunctionNode struct {
Name string
Args []Node
}
// BinaryNode represents a binary operator.
type BinaryNode struct {
Op string
Left Node
Right Node
}
// UnaryNode represents a unary operator.
type UnaryNode struct {
Op string
Operand Node
}
// Parser holds the lexer and the stacks used by the shuntingyard algorithm.
type Parser struct {
lexer *Lexer
tokens []Token
pos int
ops []OpToken
vals []Node
}
type OpToken struct {
Token
StartPos int
}
func precedence(tkn Token) int {
switch tkn.Kind {
case TokenKindStartGroup:
return 20
case TokenKindStartIndex, TokenKindStartParameters, TokenKindDereference:
return 19
case TokenKindLogicalOperator:
switch tkn.Raw {
case "!":
return 16
case ">", ">=", "<", "<=":
return 11
case "==", "!=":
return 10
case "&&":
return 6
case "||":
return 5
}
case TokenKindEndGroup, TokenKindEndIndex, TokenKindEndParameters, TokenKindSeparator:
return 1
}
return 0
}
// Parse parses the expression and returns the root node.
func Parse(expression string) (Node, error) {
lexer := NewLexer(expression, 0)
p := &Parser{}
// Tokenise all tokens
if err := p.initWithLexer(lexer); err != nil {
return nil, err
}
return p.parse()
}
func (p *Parser) parse() (Node, error) {
// Shuntingyard algorithm
for p.pos < len(p.tokens) {
tok := p.tokens[p.pos]
p.pos++
switch tok.Kind {
case TokenKindNumber, TokenKindString, TokenKindBoolean, TokenKindNull:
p.pushValue(&ValueNode{Kind: tok.Kind, Value: tok.Value})
case TokenKindNamedValue, TokenKindPropertyName, TokenKindWildcard:
p.pushValue(&ValueNode{Kind: tok.Kind, Value: tok.Raw})
case TokenKindFunction:
p.pushFunc(tok, len(p.vals))
case TokenKindStartParameters, TokenKindStartGroup, TokenKindStartIndex, TokenKindLogicalOperator, TokenKindDereference:
if err := p.pushOp(tok); err != nil {
return nil, err
}
case TokenKindSeparator:
if err := p.popGroup(TokenKindStartParameters); err != nil {
return nil, err
}
case TokenKindEndParameters:
if err := p.pushFuncValue(); err != nil {
return nil, err
}
case TokenKindEndGroup:
if err := p.popGroup(TokenKindStartGroup); err != nil {
return nil, err
}
p.ops = p.ops[:len(p.ops)-1]
case TokenKindEndIndex:
if err := p.popGroup(TokenKindStartIndex); err != nil {
return nil, err
}
// pop the start parameters
p.ops = p.ops[:len(p.ops)-1]
right := p.vals[len(p.vals)-1]
p.vals = p.vals[:len(p.vals)-1]
left := p.vals[len(p.vals)-1]
p.vals = p.vals[:len(p.vals)-1]
p.vals = append(p.vals, &BinaryNode{Op: "[", Left: left, Right: right})
}
}
for len(p.ops) > 0 {
if err := p.popOp(); err != nil {
return nil, err
}
}
if len(p.vals) != 1 {
return nil, errors.New("invalid expression")
}
return p.vals[0], nil
}
func (p *Parser) pushFuncValue() error {
if err := p.popGroup(TokenKindStartParameters); err != nil {
return err
}
// pop the start parameters
p.ops = p.ops[:len(p.ops)-1]
// create function node
fnTok := p.ops[len(p.ops)-1]
if fnTok.Kind != TokenKindFunction {
return errors.New("expected function token")
}
p.ops = p.ops[:len(p.ops)-1]
// collect arguments
args := []Node{}
for len(p.vals) > fnTok.StartPos {
args = append([]Node{p.vals[len(p.vals)-1]}, args...)
p.vals = p.vals[:len(p.vals)-1]
}
p.pushValue(&FunctionNode{Name: fnTok.Raw, Args: args})
return nil
}
func (p *Parser) initWithLexer(lexer *Lexer) error {
p.lexer = lexer
for {
tok := lexer.Next()
if tok == nil {
break
}
if tok.Kind == TokenKindUnexpected {
return fmt.Errorf("unexpected token %s at position %d", tok.Raw, tok.Index)
}
p.tokens = append(p.tokens, *tok)
}
return nil
}
func (p *Parser) popGroup(kind TokenKind) error {
for len(p.ops) > 0 && p.ops[len(p.ops)-1].Kind != kind {
if err := p.popOp(); err != nil {
return err
}
}
if len(p.ops) == 0 {
return errors.New("mismatched parentheses")
}
return nil
}
func (p *Parser) pushValue(v Node) {
p.vals = append(p.vals, v)
}
func (p *Parser) pushOp(t Token) error {
for len(p.ops) > 0 {
top := p.ops[len(p.ops)-1]
if precedence(top.Token) >= precedence(t) &&
top.Kind != TokenKindStartGroup &&
top.Kind != TokenKindStartIndex &&
top.Kind != TokenKindStartParameters &&
top.Kind != TokenKindSeparator {
if err := p.popOp(); err != nil {
return err
}
} else {
break
}
}
p.ops = append(p.ops, OpToken{Token: t})
return nil
}
func (p *Parser) pushFunc(t Token, start int) {
p.ops = append(p.ops, OpToken{Token: t, StartPos: start})
}
func (p *Parser) popOp() error {
if len(p.ops) == 0 {
return nil
}
op := p.ops[len(p.ops)-1]
p.ops = p.ops[:len(p.ops)-1]
switch op.Kind {
case TokenKindLogicalOperator:
if op.Raw == "!" {
if len(p.vals) < 1 {
return errors.New("insufficient operands")
}
right := p.vals[len(p.vals)-1]
p.vals = p.vals[:len(p.vals)-1]
p.vals = append(p.vals, &UnaryNode{Op: op.Raw, Operand: right})
} else {
if len(p.vals) < 2 {
return errors.New("insufficient operands")
}
right := p.vals[len(p.vals)-1]
left := p.vals[len(p.vals)-2]
p.vals = p.vals[:len(p.vals)-2]
p.vals = append(p.vals, &BinaryNode{Op: op.Raw, Left: left, Right: right})
}
case TokenKindStartParameters:
// unary operator '!' handled elsewhere
case TokenKindDereference:
if len(p.vals) < 2 {
return errors.New("insufficient operands")
}
right := p.vals[len(p.vals)-1]
left := p.vals[len(p.vals)-2]
p.vals = p.vals[:len(p.vals)-2]
p.vals = append(p.vals, &BinaryNode{Op: ".", Left: left, Right: right})
}
return nil
}
// String returns a string representation of the node.
func (n *ValueNode) String() string { return fmt.Sprintf("%v", n.Value) }
// String returns a string representation of the node.
func (n *FunctionNode) String() string {
return fmt.Sprintf("%s(%s)", n.Name, strings.Join(funcArgs(n.Args), ", "))
}
func funcArgs(args []Node) []string {
res := []string{}
for _, a := range args {
res = append(res, a.String())
}
return res
}
// String returns a string representation of the node.
func (n *BinaryNode) String() string {
return fmt.Sprintf("(%s %s %s)", n.Left.String(), n.Op, n.Right.String())
}
// String returns a string representation of the node.
func (n *UnaryNode) String() string { return fmt.Sprintf("(%s%s)", n.Op, n.Operand.String()) }
func VisitNode(exprNode Node, callback func(node Node)) {
callback(exprNode)
switch node := exprNode.(type) {
case *FunctionNode:
for _, arg := range node.Args {
VisitNode(arg, callback)
}
case *UnaryNode:
VisitNode(node.Operand, callback)
case *BinaryNode:
VisitNode(node.Left, callback)
VisitNode(node.Right, callback)
}
}

361
internal/expr/lexer.go Normal file
View File

@@ -0,0 +1,361 @@
package workflow
import (
"math"
"slices"
"strconv"
"strings"
"unicode"
)
// TokenKind represents the type of token returned by the lexer.
// The values mirror the C# TokenKind enum.
//
// Note: The names are kept identical to the C# implementation for
// easier mapping when porting the parser.
//
// The lexer is intentionally simple it only tokenises the subset of
// expressions that are used in GitHub Actions workflow `if:` expressions.
// It does not evaluate the expression that is left to the parser.
type TokenKind int
const (
TokenKindStartGroup TokenKind = iota
TokenKindStartIndex
TokenKindEndGroup
TokenKindEndIndex
TokenKindSeparator
TokenKindDereference
TokenKindWildcard
TokenKindLogicalOperator
TokenKindNumber
TokenKindString
TokenKindBoolean
TokenKindNull
TokenKindPropertyName
TokenKindFunction
TokenKindNamedValue
TokenKindStartParameters
TokenKindEndParameters
TokenKindUnexpected
)
// Token represents a single lexical token.
// Raw holds the original text, Value holds the parsed value when applicable.
// Index is the start position in the source string.
//
// The struct is intentionally minimal it only contains what the parser
// needs. If you need more information (e.g. token length) you can add it.
type Token struct {
Kind TokenKind
Raw string
Value interface{}
Index int
}
// Lexer holds the state while tokenising an expression.
// It is a direct port of the C# LexicalAnalyzer.
//
// Flags can be used to enable/disable features for now we only support
// a single flag that mirrors ExpressionFlags.DTExpressionsV1.
//
// The lexer is not threadsafe reuse a single instance per expression.
type Lexer struct {
expr string
flags int
index int
last *Token
stack []TokenKind // unclosed start tokens
}
// NewLexer creates a new lexer for the given expression.
func NewLexer(expr string, flags int) *Lexer {
return &Lexer{expr: expr, flags: flags}
}
func testTokenBoundary(c rune) bool {
switch c {
case '(', '[', ')', ']', ',', '.',
'!', '>', '<', '=', '&', '|':
return true
default:
return unicode.IsSpace(c)
}
}
// Next returns the next token or nil if the end of the expression is reached.
func (l *Lexer) Next() *Token {
// Skip whitespace
for l.index < len(l.expr) && unicode.IsSpace(rune(l.expr[l.index])) {
l.index++
}
if l.index >= len(l.expr) {
return nil
}
c := l.expr[l.index]
switch c {
case '(':
l.index++
// Function call or logical grouping
if l.last != nil && l.last.Kind == TokenKindFunction {
return l.createToken(TokenKindStartParameters, "(")
}
if l.flags&FlagV1 != 0 {
// V1 does not support grouping treat as unexpected
return l.createToken(TokenKindUnexpected, "(")
}
return l.createToken(TokenKindStartGroup, "(")
case '[':
l.index++
return l.createToken(TokenKindStartIndex, "[")
case ')':
l.index++
if len(l.stack) > 0 && l.stack[len(l.stack)-1] == TokenKindStartParameters {
return l.createToken(TokenKindEndParameters, ")")
}
return l.createToken(TokenKindEndGroup, ")")
case ']':
l.index++
return l.createToken(TokenKindEndIndex, "]")
case ',':
l.index++
return l.createToken(TokenKindSeparator, ",")
case '*':
l.index++
return l.createToken(TokenKindWildcard, "*")
case '\'':
return l.readString()
case '!', '>', '<', '=', '&', '|':
if l.flags&FlagV1 != 0 {
l.index++
return l.createToken(TokenKindUnexpected, string(c))
}
return l.readOperator()
default:
return l.defaultNext(c)
}
}
func (l *Lexer) defaultNext(c byte) *Token {
if c == '.' {
// Could be number or dereference
if l.last == nil || l.last.Kind == TokenKindSeparator || l.last.Kind == TokenKindStartGroup || l.last.Kind == TokenKindStartIndex || l.last.Kind == TokenKindStartParameters || l.last.Kind == TokenKindLogicalOperator {
return l.readNumber()
}
l.index++
return l.createToken(TokenKindDereference, ".")
}
if c == '-' || c == '+' || unicode.IsDigit(rune(c)) {
return l.readNumber()
}
return l.readKeyword()
}
// Helper to create a token and update lexer state.
func (l *Lexer) createToken(kind TokenKind, raw string) *Token {
// Token order check
if !l.checkLastToken(kind, raw) {
// Illegal token sequence
return &Token{Kind: TokenKindUnexpected, Raw: raw, Index: l.index}
}
tok := &Token{Kind: kind, Raw: raw, Index: l.index}
l.last = tok
// Manage stack for grouping
switch kind {
case TokenKindStartGroup, TokenKindStartIndex, TokenKindStartParameters:
l.stack = append(l.stack, kind)
case TokenKindEndGroup, TokenKindEndIndex, TokenKindEndParameters:
if len(l.stack) > 0 {
l.stack = l.stack[:len(l.stack)-1]
}
}
return tok
}
// nil last token represented by nil
func (l *Lexer) getLastKind() *TokenKind {
var lastKind *TokenKind
if l.last != nil {
lastKind = &l.last.Kind
}
return lastKind
}
// checkLastToken verifies that the token sequence is legal based on the last token.
func (l *Lexer) checkLastToken(kind TokenKind, raw string) bool {
lastKind := l.getLastKind()
// Helper to check if lastKind is in allowed list
allowed := func(allowedKinds ...TokenKind) bool {
return lastKind != nil && slices.Contains(allowedKinds, *lastKind)
}
// For nil last, we treat as no previous token
// Define allowed previous kinds for each token kind
switch kind {
case TokenKindStartGroup:
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartGroup, TokenKindStartParameters, TokenKindStartIndex, TokenKindLogicalOperator)
case TokenKindStartIndex:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindStartParameters:
return allowed(TokenKindFunction)
case TokenKindEndGroup:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindEndIndex:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindEndParameters:
return allowed(TokenKindStartParameters, TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindSeparator:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindWildcard:
return allowed(TokenKindStartIndex, TokenKindDereference)
case TokenKindDereference:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindLogicalOperator:
if raw == "!" { // "!"
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartGroup, TokenKindStartParameters, TokenKindStartIndex, TokenKindLogicalOperator)
}
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString:
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartIndex, TokenKindStartGroup, TokenKindStartParameters, TokenKindLogicalOperator)
case TokenKindPropertyName:
return allowed(TokenKindDereference)
case TokenKindFunction, TokenKindNamedValue:
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartIndex, TokenKindStartGroup, TokenKindStartParameters, TokenKindLogicalOperator)
default:
return true
}
}
// readNumber parses a numeric literal.
func (l *Lexer) readNumber() *Token {
start := l.index
periods := 0
for l.index < len(l.expr) {
ch := l.expr[l.index]
if ch == '.' {
periods++
}
if testTokenBoundary(rune(ch)) && ch != '.' {
break
}
l.index++
}
raw := l.expr[start:l.index]
if len(raw) > 2 {
switch raw[:2] {
case "0x", "0o":
tok := l.createToken(TokenKindNumber, raw)
if i, err := strconv.ParseInt(raw, 0, 32); err == nil {
tok.Value = float64(i)
return tok
}
}
}
// Try to parse as float64
var val interface{} = raw
if f, err := strconv.ParseFloat(raw, 64); err == nil {
val = f
}
tok := l.createToken(TokenKindNumber, raw)
tok.Value = val
return tok
}
// readString parses a singlequoted string literal.
func (l *Lexer) readString() *Token {
start := l.index
l.index++ // skip opening quote
var sb strings.Builder
closed := false
for l.index < len(l.expr) {
ch := l.expr[l.index]
l.index++
if ch == '\'' {
if l.index < len(l.expr) && l.expr[l.index] == '\'' {
// escaped quote
sb.WriteByte('\'')
l.index++
continue
}
closed = true
break
}
sb.WriteByte(ch)
}
raw := l.expr[start:l.index]
tok := l.createToken(TokenKindString, raw)
if closed {
tok.Value = sb.String()
} else {
tok.Kind = TokenKindUnexpected
}
return tok
}
// readOperator parses logical operators (==, !=, >, >=, etc.).
func (l *Lexer) readOperator() *Token {
start := l.index
l.index++
if l.index < len(l.expr) {
two := l.expr[start : l.index+1]
switch two {
case "!=", ">=", "<=", "==", "&&", "||":
l.index++
return l.createToken(TokenKindLogicalOperator, two)
}
}
ch := l.expr[start]
switch ch {
case '!', '>', '<':
return l.createToken(TokenKindLogicalOperator, string(ch))
}
return l.createToken(TokenKindUnexpected, string(ch))
}
// readKeyword parses identifiers, booleans, null, etc.
func (l *Lexer) readKeyword() *Token {
start := l.index
for l.index < len(l.expr) && !unicode.IsSpace(rune(l.expr[l.index])) && !strings.ContainsRune("()[],.!<>==&|*", rune(l.expr[l.index])) {
l.index++
}
raw := l.expr[start:l.index]
if l.last != nil && l.last.Kind == TokenKindDereference {
return l.createToken(TokenKindPropertyName, raw)
}
switch raw {
case "true":
tok := l.createToken(TokenKindBoolean, raw)
tok.Value = true
return tok
case "false":
tok := l.createToken(TokenKindBoolean, raw)
tok.Value = false
return tok
case "null":
return l.createToken(TokenKindNull, raw)
case "NaN":
tok := l.createToken(TokenKindNumber, raw)
tok.Value = math.NaN()
return tok
case "Infinity":
tok := l.createToken(TokenKindNumber, raw)
tok.Value = math.Inf(1)
return tok
}
if l.index < len(l.expr) && l.expr[l.index] == '(' {
return l.createToken(TokenKindFunction, raw)
}
return l.createToken(TokenKindNamedValue, raw)
}
// Flag constants only V1 is used for now.
const FlagV1 = 1
// UnclosedTokens returns the stack of unclosed start tokens.
func (l *Lexer) UnclosedTokens() []TokenKind {
return l.stack
}

View File

@@ -0,0 +1,112 @@
package workflow
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestLexerMultiple runs a set of expressions through the lexer and
// verifies that the produced token kinds and values match expectations.
func TestLexerMultiple(t *testing.T) {
cases := []struct {
expr string
expected []TokenKind
values []interface{} // optional, nil if not checking values
}{
{
expr: "github.event_name == 'push'",
expected: []TokenKind{
TokenKindNamedValue, // github
TokenKindDereference,
TokenKindPropertyName, // event_name
TokenKindLogicalOperator, // ==
TokenKindString, // 'push'
},
},
{
expr: "github.event_name == 'push' && github.ref == 'refs/heads/main'",
expected: []TokenKind{
TokenKindNamedValue, TokenKindDereference, TokenKindPropertyName, TokenKindLogicalOperator, TokenKindString,
TokenKindLogicalOperator, // &&
TokenKindNamedValue, TokenKindDereference, TokenKindPropertyName, TokenKindLogicalOperator, TokenKindString,
},
},
{
expr: "contains(github.ref, 'refs/heads/')",
expected: []TokenKind{
TokenKindFunction, // contains
TokenKindStartParameters,
TokenKindNamedValue, TokenKindDereference, TokenKindPropertyName, // github.ref
TokenKindSeparator,
TokenKindString,
TokenKindEndParameters,
},
},
{
expr: "matrix[0].name",
expected: []TokenKind{
TokenKindNamedValue, // matrix
TokenKindStartIndex,
TokenKindNumber,
TokenKindEndIndex,
TokenKindDereference,
TokenKindPropertyName, // name
},
},
{
expr: "github.*",
expected: []TokenKind{
TokenKindNamedValue, TokenKindDereference, TokenKindWildcard,
},
},
{
expr: "null",
expected: []TokenKind{TokenKindNull},
},
{
expr: "true",
expected: []TokenKind{TokenKindBoolean},
values: []interface{}{true},
},
{
expr: "123",
expected: []TokenKind{TokenKindNumber},
values: []interface{}{123.0},
},
{
expr: "(a && b)",
expected: []TokenKind{TokenKindStartGroup, TokenKindNamedValue, TokenKindLogicalOperator, TokenKindNamedValue, TokenKindEndGroup},
},
{
expr: "[1,2]", // Syntax Error
expected: []TokenKind{TokenKindUnexpected, TokenKindNumber, TokenKindSeparator, TokenKindNumber, TokenKindEndIndex},
},
{
expr: "'Hello i''s escaped'",
expected: []TokenKind{TokenKindString},
values: []interface{}{"Hello i's escaped"},
},
}
for _, tc := range cases {
lexer := NewLexer(tc.expr, 0)
var tokens []*Token
for {
tok := lexer.Next()
if tok == nil {
break
}
tokens = append(tokens, tok)
}
assert.Equal(t, len(tc.expected), len(tokens), "expression: %s", tc.expr)
for i, kind := range tc.expected {
assert.Equal(t, kind, tokens[i].Kind, "expr %s token %d", tc.expr, i)
}
if tc.values != nil {
for i, val := range tc.values {
assert.Equal(t, val, tokens[i].Value, "expr %s token %d value", tc.expr, i)
}
}
}
}

View File

@@ -0,0 +1,56 @@
package workflow
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLexer(t *testing.T) {
input := "github.event_name == 'push' && github.ref == 'refs/heads/main'"
lexer := NewLexer(input, 0)
var tokens []*Token
for {
tok := lexer.Next()
if tok == nil || tok.Kind == TokenKindUnexpected {
break
}
tokens = append(tokens, tok)
}
for i, tok := range tokens {
t.Logf("Token %d: Kind=%v, Value=%v", i, tok.Kind, tok.Value)
}
assert.Equal(t, tokens[1].Kind, TokenKindDereference)
}
func TestLexerNumbers(t *testing.T) {
table := []struct {
in string
out interface{}
}{
{"-Infinity", math.Inf(-1)},
{"Infinity", math.Inf(1)},
{"2.5", float64(2.5)},
{"3.3", float64(3.3)},
{"1", float64(1)},
{"-1", float64(-1)},
{"0x34", float64(0x34)},
{"0o34", float64(0o34)},
}
for _, cs := range table {
lexer := NewLexer(cs.in, 0)
var tokens []*Token
for {
tok := lexer.Next()
if tok == nil || tok.Kind == TokenKindUnexpected {
break
}
tokens = append(tokens, tok)
}
require.Len(t, tokens, 1)
assert.Equal(t, cs.out, tokens[0].Value)
assert.Equal(t, cs.in, tokens[0].Raw)
}
}

30
internal/model/anchors.go Normal file
View File

@@ -0,0 +1,30 @@
package model
import (
"errors"
"gopkg.in/yaml.v3"
)
// Assumes there is no cycle ensured via test TestVerifyCycleIsInvalid
func resolveAliases(node *yaml.Node) error {
switch node.Kind {
case yaml.AliasNode:
aliasTarget := node.Alias
if aliasTarget == nil {
return errors.New("unresolved alias node")
}
*node = *aliasTarget
if err := resolveAliases(node); err != nil {
return err
}
case yaml.DocumentNode, yaml.MappingNode, yaml.SequenceNode:
for _, child := range node.Content {
if err := resolveAliases(child); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,242 @@
package model
import (
"errors"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// TraceWriter is an interface for logging trace information.
// Implementations can write to console, file, or any other sink.
type TraceWriter interface {
Info(format string, args ...interface{})
}
// StrategyResult holds the result of expanding a strategy.
// FlatMatrix contains the expanded matrix entries.
// IncludeMatrix contains entries that were added via include.
// FailFast indicates whether the job should fail fast.
// MaxParallel is the maximum parallelism allowed.
// MatrixKeys is the set of keys present in the matrix.
type StrategyResult struct {
FlatMatrix []map[string]yaml.Node
IncludeMatrix []map[string]yaml.Node
FailFast bool
MaxParallel *float64
MatrixKeys map[string]struct{}
}
type strategyContext struct {
jobTraceWriter TraceWriter
failFast bool
maxParallel float64
matrix map[string][]yaml.Node
flatMatrix []map[string]yaml.Node
includeMatrix []map[string]yaml.Node
include []yaml.Node
exclude []yaml.Node
}
func (strategyContext *strategyContext) handleInclude() error {
// Handle include logic
if len(strategyContext.include) > 0 {
for _, incNode := range strategyContext.include {
if incNode.Kind != yaml.MappingNode {
return fmt.Errorf("include entry is not a mapping node")
}
incMap := make(map[string]yaml.Node)
for i := 0; i < len(incNode.Content); i += 2 {
keyNode := incNode.Content[i]
valNode := incNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("include key is not scalar")
}
incMap[keyNode.Value] = *valNode
}
matched := false
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range incMap {
if rv, ok := row[k]; ok && !nodesEqual(rv, v) {
match = false
break
}
}
if match {
matched = true
// Add missing keys
strategyContext.jobTraceWriter.Info("Add missing keys %v", incMap)
for k, v := range incMap {
if _, ok := row[k]; !ok {
row[k] = v
}
}
}
}
if !matched {
if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Append include entry %v", incMap)
}
strategyContext.includeMatrix = append(strategyContext.includeMatrix, incMap)
}
}
}
return nil
}
func (strategyContext *strategyContext) handleExclude() error {
// Handle exclude logic
if len(strategyContext.exclude) > 0 {
for _, exNode := range strategyContext.exclude {
// exNode is expected to be a mapping node
if exNode.Kind != yaml.MappingNode {
return fmt.Errorf("exclude entry is not a mapping node")
}
// Convert mapping to map[string]yaml.Node
exMap := make(map[string]yaml.Node)
for i := 0; i < len(exNode.Content); i += 2 {
keyNode := exNode.Content[i]
valNode := exNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("exclude key is not scalar")
}
exMap[keyNode.Value] = *valNode
}
// Remove matching rows
filtered := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range exMap {
if rv, ok := row[k]; !ok || !nodesEqual(rv, v) {
match = false
break
}
}
if !match {
filtered = append(filtered, row)
} else if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Removing %v from matrix due to exclude entry %v", row, exMap)
}
}
strategyContext.flatMatrix = filtered
}
}
return nil
}
// ExpandStrategy expands the given strategy into a flat matrix and include matrix.
// It mimics the behavior of the C# StrategyUtils. The strategy parameter is expected
// to be populated from a YAML mapping that follows the GitHub Actions strategy schema.
func ExpandStrategy(strategy *Strategy, jobTraceWriter TraceWriter) (*StrategyResult, error) {
if strategy == nil {
return &StrategyResult{FlatMatrix: []map[string]yaml.Node{{}}, IncludeMatrix: []map[string]yaml.Node{}, FailFast: true}, nil
}
// Initialize defaults
strategyContext := &strategyContext{
jobTraceWriter: jobTraceWriter,
failFast: strategy.FailFast,
maxParallel: strategy.MaxParallel,
matrix: strategy.Matrix,
flatMatrix: []map[string]yaml.Node{{}},
}
// Process matrix entries
for key, values := range strategyContext.matrix {
switch key {
case "include":
strategyContext.include = values
case "exclude":
strategyContext.exclude = values
default:
// Other keys are treated as matrix dimensions
// Expand each existing row with the new key/value pairs
next := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
for _, val := range values {
newRow := make(map[string]yaml.Node)
for k, v := range row {
newRow[k] = v
}
newRow[key] = val
next = append(next, newRow)
}
}
strategyContext.flatMatrix = next
}
}
if err := strategyContext.handleExclude(); err != nil {
return nil, err
}
if len(strategyContext.flatMatrix) == 0 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Matrix is empty, adding an empty entry")
}
strategyContext.flatMatrix = []map[string]yaml.Node{{}}
}
// Enforce job matrix limit of github
if len(strategyContext.flatMatrix) > 256 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Failure: Matrix contains more than 256 entries after exclude")
}
return nil, errors.New("matrix contains more than 256 entries")
}
// Build matrix keys set
matrixKeys := make(map[string]struct{})
if len(strategyContext.flatMatrix) > 0 {
for k := range strategyContext.flatMatrix[0] {
matrixKeys[k] = struct{}{}
}
}
if err := strategyContext.handleInclude(); err != nil {
return nil, err
}
return &StrategyResult{
FlatMatrix: strategyContext.flatMatrix,
IncludeMatrix: strategyContext.includeMatrix,
FailFast: strategyContext.failFast,
MaxParallel: &strategyContext.maxParallel,
MatrixKeys: matrixKeys,
}, nil
}
// nodesEqual compares two yaml.Node values for equality.
func nodesEqual(a, b yaml.Node) bool {
return DeepEquals(a, b, true)
}
// GetDefaultDisplaySuffix returns a string like "(foo, bar, baz)".
// Empty items are ignored. If all items are empty the result is "".
func GetDefaultDisplaySuffix(items []string) string {
var b strings.Builder // efficient string concatenation
first := true // true until we write the first nonempty item
for _, mk := range items {
if mk == "" { // Go has no null string, so we only need to check for empty
continue
}
if first {
b.WriteString("(")
first = false
} else {
b.WriteString(", ")
}
b.WriteString(mk)
}
if !first { // we wrote at least one item
b.WriteString(")")
}
return b.String()
}

View File

@@ -0,0 +1,68 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
type EmptyTraceWriter struct {
}
func (e *EmptyTraceWriter) Info(_ string, _ ...interface{}) {
}
func TestStrategy(t *testing.T) {
table := []struct {
content string
flatmatrix int
includematrix int
}{
{`
matrix:
label:
- a
- b
fields:
- a
- b
`, 4, 0},
{`
matrix:
label:
- a
- b
include:
- label: a
x: self`, 2, 0,
},
{`
matrix:
label:
- a
- b
include:
- label: c
x: self`, 2, 1,
},
{`
matrix:
label:
- a
- b
exclude:
- label: a`, 1, 0,
},
}
for _, tc := range table {
var strategy Strategy
err := yaml.Unmarshal([]byte(tc.content), &strategy)
require.NoError(t, err)
res, err := ExpandStrategy(&strategy, &EmptyTraceWriter{})
require.NoError(t, err)
require.Len(t, res.FlatMatrix, tc.flatmatrix)
require.Len(t, res.IncludeMatrix, tc.includematrix)
}
}

View File

@@ -0,0 +1,148 @@
package model
import (
"strings"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"gopkg.in/yaml.v3"
)
// DeepEquals compares two yaml.Node values recursively.
// It supports scalar, mapping and sequence nodes and allows
// an optional partial match for mappings and sequences.
func DeepEquals(a, b yaml.Node, partialMatch bool) bool {
// Scalar comparison
if a.Kind == yaml.ScalarNode && b.Kind == yaml.ScalarNode {
return scalarEquals(a, b)
}
// Mapping comparison
if a.Kind == yaml.MappingNode && b.Kind == yaml.MappingNode {
return deepMapEquals(a, b, partialMatch)
}
// Sequence comparison
if a.Kind == yaml.SequenceNode && b.Kind == yaml.SequenceNode {
return deepSequenceEquals(a, b, partialMatch)
}
// Different kinds are not equal
return false
}
func scalarEquals(a, b yaml.Node) bool {
var left, right any
return a.Decode(&left) == nil && b.Decode(&right) == nil && v2.CreateIntermediateResult(v2.NewEvaluationContext(), left).AbstractEqual(v2.CreateIntermediateResult(v2.NewEvaluationContext(), right))
}
func deepMapEquals(a, b yaml.Node, partialMatch bool) bool {
mapA := make(map[string]yaml.Node)
for i := 0; i < len(a.Content); i += 2 {
keyNode := a.Content[i]
valNode := a.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return false
}
mapA[strings.ToLower(keyNode.Value)] = *valNode
}
mapB := make(map[string]yaml.Node)
for i := 0; i < len(b.Content); i += 2 {
keyNode := b.Content[i]
valNode := b.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return false
}
mapB[strings.ToLower(keyNode.Value)] = *valNode
}
if partialMatch {
if len(mapA) < len(mapB) {
return false
}
} else {
if len(mapA) != len(mapB) {
return false
}
}
for k, vB := range mapB {
vA, ok := mapA[k]
if !ok || !DeepEquals(vA, vB, partialMatch) {
return false
}
}
return true
}
func deepSequenceEquals(a, b yaml.Node, partialMatch bool) bool {
if partialMatch {
if len(a.Content) < len(b.Content) {
return false
}
} else {
if len(a.Content) != len(b.Content) {
return false
}
}
limit := len(b.Content)
if !partialMatch {
limit = len(a.Content)
}
for i := 0; i < limit; i++ {
if !DeepEquals(*a.Content[i], *b.Content[i], partialMatch) {
return false
}
}
return true
}
// traverse walks a YAML node recursively.
func traverse(node *yaml.Node, omitKeys bool, result *[]*yaml.Node) {
if node == nil {
return
}
*result = append(*result, node)
switch node.Kind {
case yaml.MappingNode:
if omitKeys {
// node.Content: key0, val0, key1, val1, …
for i := 1; i < len(node.Content); i += 2 { // only the values
traverse(node.Content[i], omitKeys, result)
}
} else {
for _, child := range node.Content {
traverse(child, omitKeys, result)
}
}
case yaml.SequenceNode:
// For all other node kinds (Scalar, Sequence, Alias, etc.)
for _, child := range node.Content {
traverse(child, omitKeys, result)
}
}
}
// GetDisplayStrings implements the LINQ expression:
//
// from displayitem in keys.SelectMany(key => item[key].Traverse(true))
// where !(displayitem is SequenceToken || displayitem is MappingToken)
// select displayitem.ToString()
func GetDisplayStrings(keys []string, item map[string]*yaml.Node) []string {
var res []string
for _, k := range keys {
if node, ok := item[k]; ok {
var all []*yaml.Node
traverse(node, true, &all) // include the parent node itself
for _, n := range all {
// Keep only scalars everything else is dropped
if n.Kind == yaml.ScalarNode {
res = append(res, n.Value)
}
}
}
}
return res
}

View File

@@ -0,0 +1,277 @@
package model
import "gopkg.in/yaml.v3"
type JobStatus int
const (
JobStatusPending JobStatus = iota
JobStatusDependenciesReady
JobStatusBlocked
JobStatusCompleted
)
type JobState struct {
JobID string // Workflow path to job, incl matrix and parent jobids
Result string // Actions Job Result
Outputs map[string]string // Returned Outputs
State JobStatus
Strategy []MatrixJobState
}
type MatrixJobState struct {
Matrix map[string]any
Name string
Result string
Outputs map[string]string // Returned Outputs
State JobStatus
}
type WorkflowStatus int
const (
WorkflowStatusPending WorkflowStatus = iota
WorkflowStatusDependenciesReady
WorkflowStatusBlocked
WorkflowStatusCompleted
)
type WorkflowState struct {
Name string
RunName string
Jobs JobState
StateWorkflowStatus WorkflowStatus
}
type Workflow struct {
On *On `yaml:"on,omitempty"`
Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
RunName yaml.Node `yaml:"run-name,omitempty"`
Permissions *Permissions `yaml:"permissions,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
Concurrency yaml.Node `yaml:"concurrency,omitempty"` // Two layouts
Jobs map[string]Job `yaml:"jobs,omitempty"`
}
type On struct {
Data map[string]yaml.Node `yaml:"-"`
WorkflowDispatch *WorkflowDispatch `yaml:"workflow_dispatch,omitempty"`
WorkflowCall *WorkflowCall `yaml:"workflow_call,omitempty"`
Schedule []Cron `yaml:"schedule,omitempty"`
}
type Cron struct {
Cron string `yaml:"cron,omitempty"`
}
func (a *On) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var s string
if err := node.Decode(&s); err != nil {
return err
}
a.Data = map[string]yaml.Node{}
a.Data[s] = yaml.Node{}
case yaml.SequenceNode:
var s []string
if err := node.Decode(&s); err != nil {
return err
}
a.Data = map[string]yaml.Node{}
for _, v := range s {
a.Data[v] = yaml.Node{}
}
default:
if err := node.Decode(&a.Data); err != nil {
return err
}
type OnObj On
if err := node.Decode((*OnObj)(a)); err != nil {
return err
}
}
return nil
}
func (a *On) MarshalYAML() (interface{}, error) {
return a.Data, nil
}
var (
_ yaml.Unmarshaler = &On{}
_ yaml.Marshaler = &On{}
_ yaml.Unmarshaler = &Concurrency{}
_ yaml.Unmarshaler = &RunsOn{}
_ yaml.Unmarshaler = &ImplicitStringArray{}
_ yaml.Unmarshaler = &Environment{}
)
type WorkflowDispatch struct {
Inputs map[string]Input `yaml:"inputs,omitempty"`
}
type Input struct {
Description string `yaml:"description,omitempty"`
Type string `yaml:"type,omitempty"`
Default string `yaml:"default,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type WorkflowCall struct {
Inputs map[string]Input `yaml:"inputs,omitempty"`
Secrets map[string]Secret `yaml:"secrets,omitempty"`
Outputs map[string]Output `yaml:"outputs,omitempty"`
}
type Secret struct {
Description string `yaml:"description,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type Output struct {
Description string `yaml:"description,omitempty"`
Value yaml.Node `yaml:"value,omitempty"`
}
type Job struct {
Needs ImplicitStringArray `yaml:"needs,omitempty"`
Permissions *Permissions `yaml:"permissions,omitempty"`
Strategy yaml.Node `yaml:"strategy,omitempty"`
Name yaml.Node `yaml:"name,omitempty"`
Concurrency yaml.Node `yaml:"concurrency,omitempty"`
// Reusable Workflow
Uses yaml.Node `yaml:"uses,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
Secrets yaml.Node `yaml:"secrets,omitempty"`
// Runner Job
RunsOn yaml.Node `yaml:"runs-on,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
TimeoutMinutes yaml.Node `yaml:"timeout-minutes,omitempty"`
Container yaml.Node `yaml:"container,omitempty"`
Services yaml.Node `yaml:"services,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
Steps []yaml.Node `yaml:"steps,omitempty"`
Outputs yaml.Node `yaml:"outputs,omitempty"`
}
type ImplicitStringArray []string
func (a *ImplicitStringArray) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
*a = []string{s}
return nil
}
return node.Decode((*[]string)(a))
}
type Permissions map[string]string
func (p *Permissions) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
var perm string
switch s {
case "read-all":
perm = "read"
case "write-all":
perm = "write"
default:
return nil
}
(*p)["actions"] = perm
(*p)["attestations"] = perm
(*p)["contents"] = perm
(*p)["checks"] = perm
(*p)["deployments"] = perm
(*p)["discussions"] = perm
(*p)["id-token"] = perm
(*p)["issues"] = perm
(*p)["models"] = perm
(*p)["packages"] = perm
(*p)["pages"] = perm
(*p)["pull-requests"] = perm
(*p)["repository-projects"] = perm
(*p)["security-events"] = perm
(*p)["statuses"] = perm
return nil
}
return node.Decode((*map[string]string)(p))
}
type Strategy struct {
Matrix map[string][]yaml.Node `yaml:"matrix"`
MaxParallel float64 `yaml:"max-parallel"`
FailFast bool `yaml:"fail-fast"`
}
type Concurrency struct {
Group string `yaml:"group"`
CancelInProgress bool `yaml:"cancel-in-progress"`
}
func (c *Concurrency) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
c.Group = s
return nil
}
type ConcurrencyObj Concurrency
return node.Decode((*ConcurrencyObj)(c))
}
type Environment struct {
Name string `yaml:"name"`
URL yaml.Node `yaml:"url"`
}
func (e *Environment) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
e.Name = s
return nil
}
type EnvironmentObj Environment
return node.Decode((*EnvironmentObj)(e))
}
type RunsOn struct {
Labels []string `yaml:"labels"`
Group string `yaml:"group,omitempty"`
}
func (a *RunsOn) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
a.Labels = []string{s}
return nil
}
if node.Kind == yaml.SequenceNode {
var s []string
if err := node.Decode(&s); err != nil {
return err
}
a.Labels = s
return nil
}
type RunsOnObj RunsOn
return node.Decode((*RunsOnObj)(a))
}

View File

@@ -0,0 +1,141 @@
package model
import (
"context"
"testing"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"github.com/actions-oss/act-cli/internal/templateeval"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestParseWorkflow(t *testing.T) {
ee := &templateeval.ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
err := yaml.Unmarshal([]byte(`
on: push
run-name: ${{ fromjson('{}') }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}
`), &node)
require.NoError(t, err)
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
ee.RestrictEval = true
ee.EvaluationContext.Variables = v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{},
"inputs": v2.CaseInsensitiveObject[any]{},
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.Error(t, err)
var myw Workflow
require.NoError(t, node.Decode(&myw))
}
func TestParseWorkflowCall(t *testing.T) {
ee := &templateeval.ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
// jobs.test.outputs.test
err := yaml.Unmarshal([]byte(`
on:
workflow_call:
outputs:
test:
value: ${{ jobs.test.outputs.test }} # tojson(vars.raw)
run-name: ${{ github.ref_name }}
jobs:
_:
runs-on: ubuntu-latest
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}
`), &node)
require.NoError(t, err)
require.NoError(t, resolveAliases(node.Content[0]))
require.NoError(t, (&schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
}).UnmarshalYAML(node.Content[0]))
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
var raw any
err = node.Content[0].Decode(&raw)
assert.NoError(t, err)
ee.RestrictEval = true
ee.EvaluationContext.Variables = v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{
"raw": raw,
},
"inputs": v2.CaseInsensitiveObject[any]{},
"jobs": v2.CaseInsensitiveObject[any]{
"test": v2.CaseInsensitiveObject[any]{
"outputs": v2.CaseInsensitiveObject[any]{
"test": "Hello World",
},
},
},
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
RestrictEval: true,
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
var myw Workflow
require.NoError(t, node.Decode(&myw))
workflowCall := myw.On.WorkflowCall
if workflowCall != nil {
for _, out := range workflowCall.Outputs {
err = ee.EvaluateYamlNode(context.Background(), &out.Value, &schema.Node{
RestrictEval: true,
Definition: "workflow-output-context",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
require.Equal(t, "Hello World", out.Value.Value)
}
}
out, err := yaml.Marshal(&myw)
assert.NoError(t, err)
assert.NotEmpty(t, out)
}

View File

@@ -0,0 +1,195 @@
package templateeval
import (
"context"
"fmt"
"regexp"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
exprparser "github.com/actions-oss/act-cli/internal/expr"
"github.com/actions-oss/act-cli/pkg/schema"
"gopkg.in/yaml.v3"
)
type ExpressionEvaluator struct {
RestrictEval bool
EvaluationContext v2.EvaluationContext
}
func isImplExpr(snode *schema.Node) bool {
def := snode.Schema.GetDefinition(snode.Definition)
return def.String != nil && def.String.IsExpression
}
func (ee ExpressionEvaluator) evaluateScalarYamlNode(_ context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
var in string
if err := node.Decode(&in); err != nil {
return nil, err
}
expr, isExpr, err := rewriteSubExpression(in, false)
if err != nil {
return nil, err
}
if snode == nil || !isExpr && !isImplExpr(snode) || snode.Schema.GetDefinition(snode.Definition).String.IsExpression || ee.RestrictEval && node.Tag != "!!expr" {
return node, nil
}
parsed, err := exprparser.Parse(expr)
if err != nil {
return nil, err
}
canEvaluate := ee.canEvaluate(parsed, snode)
if !canEvaluate {
node.Tag = "!!expr"
return node, nil
}
eval := v2.NewEvaluator(&ee.EvaluationContext)
res, err := eval.EvaluateRaw(expr)
if err != nil {
return nil, err
}
ret := &yaml.Node{}
if err := ret.Encode(res); err != nil {
return nil, err
}
ret.Line = node.Line
ret.Column = node.Column
// Finally check if we found a schema validation error
return ret, snode.UnmarshalYAML(ret)
}
func (ee ExpressionEvaluator) canEvaluate(parsed exprparser.Node, snode *schema.Node) bool {
canEvaluate := true
for _, v := range snode.GetVariables() {
canEvaluate = canEvaluate && ee.EvaluationContext.Variables.Get(v) != nil
}
for _, v := range snode.GetFunctions() {
canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(v.Name) != nil
}
exprparser.VisitNode(parsed, func(node exprparser.Node) {
switch el := node.(type) {
case *exprparser.FunctionNode:
canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(el.Name) != nil
case *exprparser.ValueNode:
canEvaluate = canEvaluate && (el.Kind != exprparser.TokenKindNamedValue || ee.EvaluationContext.Variables.Get(el.Value.(string)) != nil)
}
})
return canEvaluate
}
func (ee ExpressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
var ret *yaml.Node
// GitHub has this undocumented feature to merge maps, called insert directive
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
for i := 0; i < len(node.Content)/2; i++ {
k := node.Content[i*2]
var sk string
shouldInsert := k.Decode(&sk) == nil && insertDirective.MatchString(sk)
changed := func() error {
if ret == nil {
ret = &yaml.Node{}
if err := ret.Encode(node); err != nil {
return err
}
ret.Content = ret.Content[:i*2]
}
return nil
}
var ek *yaml.Node
if !shouldInsert {
var err error
ek, err = ee.evaluateYamlNodeInternal(ctx, k, snode)
if err != nil {
return nil, err
}
if ek != nil {
if err := changed(); err != nil {
return nil, err
}
} else {
ek = k
}
}
v := node.Content[i*2+1]
ev, err := ee.evaluateYamlNodeInternal(ctx, v, snode.GetNestedNode(ek.Value))
if err != nil {
return nil, err
}
if ev != nil {
if err := changed(); err != nil {
return nil, err
}
} else {
ev = v
}
// Merge the nested map of the insert directive
if shouldInsert {
if ev.Kind != yaml.MappingNode {
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
}
if err := changed(); err != nil {
return nil, err
}
ret.Content = append(ret.Content, ev.Content...)
} else if ret != nil {
ret.Content = append(ret.Content, ek, ev)
}
}
return ret, nil
}
func (ee ExpressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
var ret *yaml.Node
for i := 0; i < len(node.Content); i++ {
v := node.Content[i]
// Preserve nested sequences
wasseq := v.Kind == yaml.SequenceNode
ev, err := ee.evaluateYamlNodeInternal(ctx, v, snode.GetNestedNode("*"))
if err != nil {
return nil, err
}
if ev != nil {
if ret == nil {
ret = &yaml.Node{}
if err := ret.Encode(node); err != nil {
return nil, err
}
ret.Content = ret.Content[:i]
}
// GitHub has this undocumented feature to merge sequences / arrays
// We have a nested sequence via evaluation, merge the arrays
if ev.Kind == yaml.SequenceNode && !wasseq {
ret.Content = append(ret.Content, ev.Content...)
} else {
ret.Content = append(ret.Content, ev)
}
} else if ret != nil {
ret.Content = append(ret.Content, v)
}
}
return ret, nil
}
func (ee ExpressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
switch node.Kind {
case yaml.ScalarNode:
return ee.evaluateScalarYamlNode(ctx, node, snode)
case yaml.MappingNode:
return ee.evaluateMappingYamlNode(ctx, node, snode)
case yaml.SequenceNode:
return ee.evaluateSequenceYamlNode(ctx, node, snode)
default:
return nil, nil
}
}
func (ee ExpressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) error {
ret, err := ee.evaluateYamlNodeInternal(ctx, node, snode)
if err != nil {
return err
}
if ret != nil {
return ret.Decode(node)
}
return nil
}

View File

@@ -0,0 +1,94 @@
package templateeval
import (
"context"
"testing"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestEval(t *testing.T) {
cases := []struct {
name string
yamlInput string
restrict bool
variables v2.CaseInsensitiveObject[any]
expectErr bool
}{
{
name: "NoError",
yamlInput: `on: push
run-name: ${{ github.ref_name }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}`,
restrict: false,
expectErr: false,
},
{
name: "Error",
yamlInput: `on: push
run-name: ${{ fromjson('{}') }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}`,
restrict: true,
variables: v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{},
"inputs": v2.CaseInsensitiveObject[any]{},
},
expectErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ee := &ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
err := yaml.Unmarshal([]byte(tc.yamlInput), &node)
require.NoError(t, err)
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
if tc.restrict {
ee.RestrictEval = true
}
if tc.variables != nil {
ee.EvaluationContext.Variables = tc.variables
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
if tc.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -0,0 +1,75 @@
package templateeval
import (
"fmt"
"regexp"
"strings"
)
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
func rewriteSubExpression(in string, forceFormat bool) (result string, isExpr bool, err error) {
// missing closing pair is an error
if !strings.Contains(in, "${{") {
return in, false, nil
}
strPattern := regexp.MustCompile("(?:''|[^'])*'")
pos := 0
exprStart := -1
strStart := -1
var results []string
formatOut := ""
for pos < len(in) {
if strStart > -1 {
matches := strPattern.FindStringIndex(in[pos:])
if matches == nil {
return "", false, fmt.Errorf("unclosed string at position %d in %s", pos, in)
}
strStart = -1
pos += matches[1]
} else if exprStart > -1 {
exprEnd := strings.Index(in[pos:], "}}")
strStart = strings.Index(in[pos:], "'")
if exprEnd > -1 && strStart > -1 {
if exprEnd < strStart {
strStart = -1
} else {
exprEnd = -1
}
}
if exprEnd > -1 {
formatOut += fmt.Sprintf("{%d}", len(results))
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
pos += exprEnd + 2
exprStart = -1
} else if strStart > -1 {
pos += strStart + 1
} else {
return "", false, fmt.Errorf("unclosed expression at position %d in %s", pos, in)
}
} else {
exprStart = strings.Index(in[pos:], "${{")
if exprStart != -1 {
formatOut += escapeFormatString(in[pos : pos+exprStart])
exprStart = pos + exprStart + 3
pos = exprStart
} else {
formatOut += escapeFormatString(in[pos:])
pos = len(in)
}
}
}
if len(results) == 1 && formatOut == "{0}" && !forceFormat {
return results[0], true, nil
}
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
return out, true, nil
}

View File

@@ -0,0 +1,115 @@
package templateeval
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRewriteSubExpression_NoExpression(t *testing.T) {
in := "Hello world"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
if ok {
t.Fatalf("expected ok=false for no expression, got true with output %q", out)
}
if out != in {
t.Fatalf("expected output %q, got %q", in, out)
}
}
func TestRewriteSubExpression_SingleExpression(t *testing.T) {
in := "Hello ${{ 'world' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true for single expression, got false")
}
expected := "format('Hello {0}', 'world')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_MultipleExpressions(t *testing.T) {
in := "Hello ${{ 'world' }}, you are ${{ 'awesome' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true for multiple expressions, got false")
}
expected := "format('Hello {0}, you are {1}', 'world', 'awesome')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_ForceFormatSingle(t *testing.T) {
in := "Hello ${{ 'world' }}"
out, ok, err := rewriteSubExpression(in, true)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true when forceFormat, got false")
}
expected := "format('Hello {0}', 'world')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_ForceFormatMultiple(t *testing.T) {
in := "Hello ${{ 'world' }}, you are ${{ 'awesome' }}"
out, ok, err := rewriteSubExpression(in, true)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true when forceFormat, got false")
}
expected := "format('Hello {0}, you are {1}', 'world', 'awesome')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_UnclosedExpression(t *testing.T) {
in := "Hello ${{ 'world' " // missing closing }}
_, _, err := rewriteSubExpression(in, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unclosed expression")
}
func TestRewriteSubExpression_UnclosedString(t *testing.T) {
in := "Hello ${{ 'world }}, you are ${{ 'awesome' }}"
_, _, err := rewriteSubExpression(in, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unclosed string")
}
func TestRewriteSubExpression_EscapedStringLiteral(t *testing.T) {
// Two single quotes represent an escaped quote inside a string
in := "Hello ${{ 'It''s a test' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
assert.True(t, ok)
expected := "format('Hello {0}', 'It''s a test')"
assert.Equal(t, expected, out)
}
func TestRewriteSubExpression_ExpressionAtEnd(t *testing.T) {
// Expression ends exactly at the string end should be valid
in := "Hello ${{ 'world' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
assert.True(t, ok)
expected := "format('Hello {0}', 'world')"
assert.Equal(t, expected, out)
}
func TestRewriteSubExpression_ExpressionNotAtEnd(t *testing.T) {
// Expression followed by additional text should still be valid
in := "Hello ${{ 'world' }}, how are you?"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
assert.True(t, ok)
expected := "format('Hello {0}, how are you?', 'world')"
assert.Equal(t, expected, out)
}