mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-03-23 23:35:03 +01:00
Replace expressions engine (#133)
This commit is contained in:
122
internal/eval/functions/format.go
Normal file
122
internal/eval/functions/format.go
Normal 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
|
||||
}
|
||||
14
internal/eval/functions/format_test.go
Normal file
14
internal/eval/functions/format_test.go
Normal 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
|
||||
}
|
||||
464
internal/eval/v2/evaluation_result.go
Normal file
464
internal/eval/v2/evaluation_result.go
Normal 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 ---------------------------------------
|
||||
276
internal/eval/v2/evaluator.go
Normal file
276
internal/eval/v2/evaluator.go
Normal 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
|
||||
}
|
||||
111
internal/eval/v2/evaluator_test.go
Normal file
111
internal/eval/v2/evaluator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
176
internal/eval/v2/functions.go
Normal file
176
internal/eval/v2/functions.go
Normal 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{},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user