rtmleval API

rtmleval

package

API reference for the rtmleval package.

F
function

Eval

Eval evaluates an RTML expression string against a variable lookup.
lookup(name) returns the value for a variable.

Parameters

expr
string
lookup
func(string) (any, bool)

Returns

any
error
rtmleval/eval.go:15-20
func Eval(expr string, lookup func(string) (any, bool)) (any, error)

{
	p := &parser{input: expr, lookup: lookup}
	p.readChar()
	p.next()
	return p.parseTernary()
}
F
function

Bool

Bool evaluates and coerces the result to bool.

Parameters

expr
string
lookup
func(string) (any, bool)

Returns

bool
error
rtmleval/eval.go:23-29
func Bool(expr string, lookup func(string) (any, bool)) (bool, error)

{
	v, err := Eval(expr, lookup)
	if err != nil {
		return false, err
	}
	return toBool(v), nil
}
F
function

String

String returns the evaluated result as string.

Parameters

expr
string
lookup
func(string) (any, bool)

Returns

string
error
rtmleval/eval.go:32-38
func String(expr string, lookup func(string) (any, bool)) (string, error)

{
	v, err := Eval(expr, lookup)
	if err != nil {
		return "", err
	}
	return toString(v), nil
}
F
function

toBool

toBool coerces a Go value to bool.

Parameters

v
any

Returns

bool
rtmleval/eval.go:41-56
func toBool(v any) bool

{
	switch val := v.(type) {
	case bool:
		return val
	case int:
		return val != 0
	case float64:
		return val != 0
	case string:
		return val != "" && val != "false" && val != "0"
	case nil:
		return false
	default:
		return v != nil
	}
}
F
function

toFloat

toFloat coerces a Go value to float64.

Parameters

v
any

Returns

float64
bool
rtmleval/eval.go:59-71
func toFloat(v any) (float64, bool)

{
	switch val := v.(type) {
	case float64:
		return val, true
	case int:
		return float64(val), true
	case string:
		f, err := strconv.ParseFloat(val, 64)
		return f, err == nil
	default:
		return 0, false
	}
}
F
function

toString

toString coerces a Go value to string.

Parameters

v
any

Returns

string
rtmleval/eval.go:74-82
func toString(v any) string

{
	if v == nil {
		return ""
	}
	if s, ok := v.(string); ok {
		return s
	}
	return fmt.Sprintf("%v", v)
}
T
type

tokenType

rtmleval/eval.go:86-86
type tokenType int
S
struct

token

rtmleval/eval.go:118-121
type token struct

Fields

Name Type Description
typ tokenType
val string
S
struct

parser

rtmleval/eval.go:123-129
type parser struct

Methods

readChar
Method
func (*parser) readChar()
{
	if p.pos >= len(p.input) {
		p.ch = 0
	} else {
		p.ch = p.input[p.pos]
	}
	p.pos++
}
peek
Method

Returns

byte
func (*parser) peek() byte
{
	if p.pos >= len(p.input) {
		return 0
	}
	return p.input[p.pos]
}
func (*parser) skipWhitespace()
{
	for p.ch != 0 && (p.ch == ' ' || p.ch == '\t' || p.ch == '\n' || p.ch == '\r') {
		p.readChar()
	}
}
readString
Method

Parameters

quote byte
func (*parser) readString(quote byte)
{
	var sb strings.Builder
	p.readChar()
	for p.ch != quote && p.ch != 0 {
		if p.ch == '\\' {
			p.readChar()
			switch p.ch {
			case 'n':
				sb.WriteByte('\n')
			case 't':
				sb.WriteByte('\t')
			case 'r':
				sb.WriteByte('\r')
			case '\\':
				sb.WriteByte('\\')
			case '"':
				sb.WriteByte('"')
			case '\'':
				sb.WriteByte('\'')
			default:
				sb.WriteByte(p.ch)
			}
		} else {
			sb.WriteByte(p.ch)
		}
		p.readChar()
	}
	if p.ch == quote {
		p.readChar()
	}
	p.cur = token{tString, sb.String()}
}
readNumber
Method
func (*parser) readNumber()
{
	start := p.pos - 1
	for isDigit(p.ch) || p.ch == '.' {
		p.readChar()
	}
	p.cur = token{tNumber, p.input[start : p.pos-1]}
}
readIdent
Method
func (*parser) readIdent()
{
	start := p.pos - 1
	for isIdentPart(p.ch) || p.ch == '-' {
		p.readChar()
	}
	val := p.input[start : p.pos-1]
	// Allow qualified store:/signal:/prop: references as a single identifier so
	// @if/@expr conditions can reference reactive sources (same refs @for uses).
	// The ternary ':' always follows an expression/space, never one of these
	// bare prefixes immediately, so this does not clash with ternary parsing.
	if (val == "store" || val == "signal" || val == "prop") && p.ch == ':' {
		p.readChar()
		for isIdentPart(p.ch) || p.ch == '-' {
			p.readChar()
		}
		p.cur = token{tIdent, p.input[start : p.pos-1]}
		return
	}
	switch val {
	case "true":
		p.cur = token{tTrue, val}
	case "false":
		p.cur = token{tFalse, val}
	case "and":
		p.cur = token{tAnd, val}
	case "or":
		p.cur = token{tOr, val}
	case "not":
		p.cur = token{tNot, val}
	case "is":
		p.cur = token{tIs, val}
	case "then":
		p.cur = token{tThen, val}
	case "else":
		p.cur = token{tElse, val}
	default:
		p.cur = token{tIdent, val}
	}
}
parseTernary
Method

parseTernary handles: cond then X else Y Also handles the symbol form: cond ? X : Y (kept for backward compat)

Returns

any
error
func (*parser) parseTernary() (any, error)
{
	cond, err := p.parseOr()
	if err != nil {
		return nil, err
	}
	if p.cur.typ == tThen {
		p.next()
		thenVal, err := p.parseOr()
		if err != nil {
			return nil, err
		}
		if p.cur.typ != tElse {
			return nil, fmt.Errorf("expected 'else' in ternary expression")
		}
		p.next()
		elseVal, err := p.parseTernary()
		if err != nil {
			return nil, err
		}
		if toBool(cond) {
			return thenVal, nil
		}
		return elseVal, nil
	}
	// Backward compat: symbol ? X : Y
	if p.cur.typ == tQuestion {
		p.next()
		thenVal, err := p.parseOr()
		if err != nil {
			return nil, err
		}
		if p.cur.typ != tColon {
			return nil, fmt.Errorf("expected ':' in ternary expression")
		}
		p.next()
		elseVal, err := p.parseTernary()
		if err != nil {
			return nil, err
		}
		if toBool(cond) {
			return thenVal, nil
		}
		return elseVal, nil
	}
	return cond, nil
}
parseOr
Method

Returns

any
error
func (*parser) parseOr() (any, error)
{
	lhs, err := p.parseAnd()
	if err != nil {
		return nil, err
	}
	for p.cur.typ == tOr {
		p.next()
		rhs, err := p.parseAnd()
		if err != nil {
			return nil, err
		}
		lhs = toBool(lhs) || toBool(rhs)
	}
	return lhs, nil
}
parseAnd
Method

Returns

any
error
func (*parser) parseAnd() (any, error)
{
	lhs, err := p.parseEquality()
	if err != nil {
		return nil, err
	}
	for p.cur.typ == tAnd {
		p.next()
		rhs, err := p.parseEquality()
		if err != nil {
			return nil, err
		}
		lhs = toBool(lhs) && toBool(rhs)
	}
	return lhs, nil
}
parseEquality
Method

parseEquality handles ==, !=, is, is not

Returns

any
error
func (*parser) parseEquality() (any, error)
{
	lhs, err := p.parseRelational()
	if err != nil {
		return nil, err
	}
	for {
		switch p.cur.typ {
		case tEq:
			p.next()
			rhs, err := p.parseRelational()
			if err != nil {
				return nil, err
			}
			lhs = cmpEqual(lhs, rhs)
		case tNeq:
			p.next()
			rhs, err := p.parseRelational()
			if err != nil {
				return nil, err
			}
			lhs = !cmpEqual(lhs, rhs)
		case tIs:
			p.next()
			// Check for "is not" (negated equality)
			if p.cur.typ == tNot {
				p.next()
				rhs, err := p.parseRelational()
				if err != nil {
					return nil, err
				}
				lhs = !cmpEqual(lhs, rhs)
			} else {
				rhs, err := p.parseRelational()
				if err != nil {
					return nil, err
				}
				lhs = cmpEqual(lhs, rhs)
			}
		default:
			return lhs, nil
		}
	}
}

Returns

any
error
func (*parser) parseRelational() (any, error)
{
	lhs, err := p.parseAdditive()
	if err != nil {
		return nil, err
	}
	for p.cur.typ == tLt || p.cur.typ == tLte || p.cur.typ == tGt || p.cur.typ == tGte {
		op := p.cur.typ
		p.next()
		rhs, err := p.parseAdditive()
		if err != nil {
			return nil, err
		}
		a, aok := toFloat(lhs)
		b, bok := toFloat(rhs)
		if !aok || !bok {
			return false, nil
		}
		switch op {
		case tLt:
			lhs = a < b
		case tLte:
			lhs = a <= b
		case tGt:
			lhs = a > b
		case tGte:
			lhs = a >= b
		}
	}
	return lhs, nil
}
parseAdditive
Method

Returns

any
error
func (*parser) parseAdditive() (any, error)
{
	lhs, err := p.parseMultiplicative()
	if err != nil {
		return nil, err
	}
	for p.cur.typ == tPlus || p.cur.typ == tMinus {
		op := p.cur.typ
		p.next()
		rhs, err := p.parseMultiplicative()
		if err != nil {
			return nil, err
		}
		a, aok := toFloat(lhs)
		b, bok := toFloat(rhs)
		if aok && bok {
			if op == tPlus {
				lhs = a + b
			} else {
				lhs = a - b
			}
		} else {
			// String concatenation.
			lhs = toString(lhs) + toString(rhs)
		}
	}
	return lhs, nil
}

Returns

any
error
func (*parser) parseMultiplicative() (any, error)
{
	lhs, err := p.parseUnary()
	if err != nil {
		return nil, err
	}
	for p.cur.typ == tStar || p.cur.typ == tSlash {
		op := p.cur.typ
		p.next()
		rhs, err := p.parseUnary()
		if err != nil {
			return nil, err
		}
		a, aok := toFloat(lhs)
		b, bok := toFloat(rhs)
		if !aok || !bok {
			return nil, fmt.Errorf("incompatible types for * or /")
		}
		if op == tStar {
			lhs = a * b
		} else {
			lhs = a / b
		}
	}
	return lhs, nil
}
parseUnary
Method

Returns

any
error
func (*parser) parseUnary() (any, error)
{
	if p.cur.typ == tNot {
		p.next()
		v, err := p.parseUnary()
		if err != nil {
			return nil, err
		}
		return !toBool(v), nil
	}
	if p.cur.typ == tMinus {
		p.next()
		v, err := p.parseUnary()
		if err != nil {
			return nil, err
		}
		f, ok := toFloat(v)
		if !ok {
			return nil, fmt.Errorf("cannot negate non-numeric value")
		}
		return -f, nil
	}
	return p.parsePrimary()
}
parsePrimary
Method

Returns

any
error
func (*parser) parsePrimary() (any, error)
{
	switch p.cur.typ {
	case tString:
		v := p.cur.val
		p.next()
		return v, nil
	case tNumber:
		v, err := strconv.ParseFloat(p.cur.val, 64)
		if err != nil {
			return nil, err
		}
		p.next()
		return v, nil
	case tTrue:
		p.next()
		return true, nil
	case tFalse:
		p.next()
		return false, nil
	case tIdent:
		name := p.cur.val
		p.next()
		// Field access: ident.ident.ident
		for p.cur.typ == tDot {
			p.next()
			if p.cur.typ != tIdent {
				return nil, fmt.Errorf("expected field name after dot")
			}
			name += "." + p.cur.val
			p.next()
		}
		// Variable lookup.
		if p.lookup != nil {
			if v, ok := p.lookup(name); ok {
				return v, nil
			}
		}
		return name, nil // fallback: return the name as a string
	case tLParen:
		p.next()
		expr, err := p.parseTernary()
		if err != nil {
			return nil, err
		}
		if p.cur.typ != tRParen {
			return nil, fmt.Errorf("expected )")
		}
		p.next()
		return expr, nil
	default:
		return nil, fmt.Errorf("unexpected token %q", p.cur.val)
	}
}

Fields

Name Type Description
input string
pos int
ch byte
cur token
lookup func(string) (any, bool)
F
function

isIdentStart

Parameters

ch
byte

Returns

bool
rtmleval/eval.go:370-370
func isIdentStart(ch byte) bool

{ return unicode.IsLetter(rune(ch)) || ch == '_' }
F
function

isIdentPart

Parameters

ch
byte

Returns

bool
rtmleval/eval.go:371-373
func isIdentPart(ch byte) bool

{
	return unicode.IsLetter(rune(ch)) || unicode.IsDigit(rune(ch)) || ch == '_' || ch == '.'
}
F
function

isDigit

Parameters

ch
byte

Returns

bool
rtmleval/eval.go:374-374
func isDigit(ch byte) bool

{ return '0' <= ch && ch <= '9' }
F
function

cmpEqual

Parameters

a
any
b
any

Returns

bool
rtmleval/eval.go:667-694
func cmpEqual(a, b any) bool

{
	// Fast paths for same type.
	switch av := a.(type) {
	case bool:
		if bv, ok := b.(bool); ok {
			return av == bv
		}
	case string:
		if bv, ok := b.(string); ok {
			return av == bv
		}
	case float64:
		if bv, ok := b.(float64); ok {
			return av == bv
		}
	case int:
		if bv, ok := b.(int); ok {
			return av == bv
		}
	}
	// Fallback: numeric comparison.
	af, aok := toFloat(a)
	bf, bok := toFloat(b)
	if aok && bok {
		return af == bf
	}
	return toString(a) == toString(b)
}
F
function

TestEvalOperators

Parameters

rtmleval/eval_test.go:7-50
func TestEvalOperators(t *testing.T)

{
	lookup := func(name string) (any, bool) {
		m := map[string]any{
			"count": 10,
			"zero":  0,
			"name":  "world",
		}
		v, ok := m[name]
		return v, ok
	}

	tests := []struct {
		expr string
		want any
	}{
		{"count == 10", true},
		{"count != 5", true},
		{"count > 5", true},
		{"count < 20", true},
		{"count >= 10", true},
		{"count <= 10", true},
		{"count > 5 && count < 20", true},
		{"count > 5 || count < 3", true},
		{"!false", true},
		{"!(count == 5)", true},
		{"\"hello\" + \" \" + \"world\"", "hello world"},
		{"zero == 0", true},
		{"'hello' + ' ' + 'world'", "hello world"},
		{"name is 'world'", true},
		{"name is not 'world'", false},
	}

	for _, tt := range tests {
		t.Run(tt.expr, func(t *testing.T) {
			got, err := Eval(tt.expr, lookup)
			if err != nil {
				t.Fatalf("eval error: %v", err)
			}
			if got != tt.want {
				t.Errorf("eval(%q) = %v, want %v", tt.expr, got, tt.want)
			}
		})
	}
}