Okra is a small expression/DSL engine written in Go. It parses and evaluates expressions against a root data object using reflection.
package main
import (
"fmt"
"github.com/coolbit/okra"
)
type User struct {
Name string
Age int
}
func main() {
e := okra.NewEngine()
data := map[string]any{
"user": &User{Name: "Alice", Age: 25},
"arr": []int{10, 20, 30},
}
v, err := e.Eval("user.Age >= 18 ? user.Name : 'minor'", data)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(v)
}| Kind | DSL syntax | Runtime type (Go) | Example | Example result |
|---|---|---|---|---|
| Boolean | true / false |
bool |
true && false |
false |
| String | '...' (single quotes) |
string |
'hi' + ' there' |
"hi there" |
| Integer | 123, 0xFF, 1_000 |
int64 |
123 + 1 |
int64(124) |
| Float | 1.25, 1e3, 1.5e2 |
float64 |
1.25 * 2 |
float64(2.5) |
Integers accept decimal, hexadecimal (0xFF), and underscore digit separators (1_000). Floats accept a decimal point and/or an exponent (1e3, 1.5e2). A malformed number (e.g. 1.2.3) is a parse error — it is never silently truncated to 0.
String literals use single quotes, and they support Go-style escape sequences (similar to Go string literals):
\n,\r,\t\\,\'\xNN(hex byte)\uNNNN/\UNNNNNNNN(Unicode)- octal escapes like
\101
Examples:
'a\nb'
'it\'s ok'
'\u0041' // "A"
The engine evaluates expressions against a root data object (data any). Member access and indexing are implemented with reflection.
Identifiers may contain Unicode letters, so non-ASCII field and map-key names work (e.g. user.名前, число + 8).
| Input type | Access | Notes | Example |
|---|---|---|---|
struct / *struct |
user.Field |
Field access, including okra / json tag names; fields promoted from exported embedded structs are reachable too |
user.Name, user.name_tag, user.EmbeddedID |
struct / *struct |
user.Method(args...) |
Method call via reflection; arguments convert losslessly only — an int that doesn't fit the parameter's range, a fractional float passed to an integer parameter, or a number passed to a string parameter is an error, never a silent wrap/truncate/rune-string | user.SayHi('hi') |
struct / *struct |
user.Method |
Getter-style: only if method has 0 inputs and >=1 outputs; a trailing error return surfaces exactly as it would for user.Method() |
user.MultiReturn |
map[K]V |
m.key or m[key] |
key is a string; if K is numeric, Okra tries to parse numeric keys from strings |
scores.1, scores[1] |
[]T / [N]T |
arr.0 or arr[0] |
Index access; invalid/out-of-range is an error in strict mode (the default), else nil |
nums.1, nums[1] |
| pointers | auto-dereference | Nil pointers are an error in strict mode (the default), else nil (except special cases like len) |
ptr.Field, ptr.Method() |
Field promotion follows Go's rules: a directly-declared field shadows a promoted one of the same name (reach the shadowed field explicitly, e.g. user.Base.Name). Only exported embedded structs are traversed — reflecting through an unexported field would panic — and a promoted field reached through a nil embedded pointer is an error in strict mode (the default), else nil.
Okra supports bracket indexing like arr[0] and it also supports index expressions.
- Examples:
arr[i],arr[1+2],matrix[row][col],scores[0+1] - Indexing works for slices/arrays and maps (via reflection)
| Name | Signature / return | Supported inputs | Example | Example result |
|---|---|---|---|---|
len |
len(x) -> int64 |
slice / array / string / map (pointers are dereferenced); any other type — or no argument — is an error |
len(tags), len(5) |
int64(2), error |
now |
now() -> int64 |
none | now() |
Unix seconds |
has |
has(obj, name) -> bool |
name is a string field/map-key/index name; resolves fields, map keys, indexes (never methods) without a strict-mode error. Structural: true if the member is there, even when its value is nil |
has(user, 'Coupon') |
true / false |
get |
get(obj, name, default) -> any |
as has, but value-level: a missing member or a nil value both yield default, so get(...) is always safe to feed into an operation |
get(scores, 'math', 0) |
value or default |
contains |
contains(s, sub) -> bool |
strings only (no coercion) | contains('hello', 'ell') |
true |
startsWith |
startsWith(s, prefix) -> bool |
strings | startsWith('hello', 'he') |
true |
endsWith |
endsWith(s, suffix) -> bool |
strings | endsWith('hello', 'lo') |
true |
lower |
lower(s) -> string |
strings | lower('HeLLo') |
"hello" |
upper |
upper(s) -> string |
strings | upper('HeLLo') |
"HELLO" |
trim |
trim(s) -> string |
strings (trims surrounding whitespace) | trim(' hi ') |
"hi" |
Function names are case-insensitive (startsWith, startswith, and STARTSWITH all resolve to the same function).
You can extend (or override) functions on a single Engine instance:
- Registration is not global.
- It only affects the current
Engine.
e := okra.NewEngine()
_ = e.RegisterFunc("add", func(args []any) (any, error) {
if len(args) != 2 {
return nil, fmt.Errorf("expected 2 args")
}
a, _ := args[0].(int64)
b, _ := args[1].(int64)
return a + b, nil
})
v, err := e.Eval("add(1, 2)", nil)
// v == int64(3)Registration is per-Engine and only affects Programs compiled after the call
(see Compiling Once — a Program snapshots
its functions at Compile time).
RegisterFunc receives its arguments already evaluated. A macro instead receives
its arguments un-evaluated (as []Expr) plus the current Context, so it can
choose whether and how to evaluate them — including re-evaluating a predicate once per
element of a collection. This is the extension point for collection operations
(any / all / filter / map); the core language deliberately ships none of them,
nor any element placeholder, so you build exactly the semantics you want.
type MacroFunc func(ctx okra.Context, args []okra.Expr) (any, error)A macro is resolved before a plain function of the same name and before the
data-method fallback. Example: an any(coll, predicate) that evaluates predicate
with each element swapped in as the root data (so a bare field name refers to the
element — an element-scoping convention chosen by this macro, not the language):
e.RegisterMacro("any", func(ctx okra.Context, args []okra.Expr) (any, error) {
coll, err := args[0].Eval(ctx)
if err != nil {
return nil, err
}
rv := reflect.ValueOf(coll)
for i := 0; i < rv.Len(); i++ {
child := ctx
child.Data = rv.Index(i).Interface() // re-root at the element
v, err := args[1].Eval(child)
if err != nil {
return nil, err
}
if b, ok := v.(bool); ok && b {
return true, nil
}
}
return false, nil
})
// any(orders, price > 100) -> true if some order's price exceeds 100Like RegisterFunc, RegisterMacro is per-Engine and only affects Programs
compiled after the call. Because a macro exposes Expr/Context, treat those types as
part of your integration's stable surface.
List literals use square brackets and can hold any expressions: [1, 2, 3], ['a', 'b'], []. They evaluate to []any.
The in operator (and its negation not in) tests membership and never panics. It binds at the comparison precedence tier, so 1 + 1 in [2, 3] parses as (1 + 1) in [2, 3].
| Left / Right | Meaning | Example | Example result |
|---|---|---|---|
x in slice/array |
any element equals x (same equality as ==) |
2 in [1, 2, 3] |
true |
key in map |
the map contains that key | 'a' in scores |
true |
sub in string |
substring test | 'ell' in 'hello' |
true |
x not in y |
negation of the above | 4 not in [1, 2, 3] |
true |
x in nil |
error — using nil as a container | 1 in missing |
error |
| other | error | 1 in 2 |
error |
status in ['active', 'trial'] ? 1 : 0
Okra is strongly typed and fail-loud: it never silently coerces one type into
another to make an operation "work". Mixing types that don't belong together is an
error, not a guessed value. This keeps a rule's meaning stable when the data's type
shifts (e.g. a value that was an int arrives as a string).
Numbers are the one unified family: all signed/unsigned integer kinds and both float
kinds interoperate (integer path when both operands are int-like, otherwise float
path). A string is never treated as a number, and nil used in any operation is
an error (see nil).
| Operator | Rule | Example | Example result |
|---|---|---|---|
+ (numbers) |
integer path if both int-like, else float path | 1 + 2, 1.5 + 2 |
int64(3), 3.5 |
+ (strings) |
string + string concatenates; any string/number mix is an error | 'a' + 'b', 'res:' + 10 |
"ab", error |
- * / |
numbers only | 10 - 3, 2.0 * 3.5 |
int64(7), 7.0 |
/ |
division by zero → ErrDivByZero; integer / integer truncates |
10 / 0, 10 / 4 |
error, int64(2) |
% |
integers; ErrModByZero, float modulo → ErrFloatModulo |
10 % 3, 1.2 % 2.0 |
int64(1), error |
An operand that is not a number (a string, nil, …) makes arithmetic an error rather
than silently yielding 0.
Checked arithmetic: integer + - * / (and unary -) that would overflow int64
return ErrIntOverflow — never a silent two's-complement wrap. Bitwise operators
(& | ^ << >>) are exempt: wrapping is their intended semantics. A host uint64
larger than MaxInt64 is not reinterpreted as negative; it is simply not a usable
number, so arithmetic/comparison with it errors.
Note that 10 / 4 is int64(2) (truncating integer division, as in Go/SQL/CEL); use
a float operand (10 / 4.0) for float division.
Both operands must be the same category — two strings (compared lexically) or two numbers (compared numerically). A string is never coerced to a number.
| Example | Result |
|---|---|
'a' < 'b' |
true (lexical) |
10.5 > 10 |
true (numeric) |
'10' > 5 |
error (string vs number) |
| Operator | Rule | Example | Example result |
|---|---|---|---|
== |
DeepEqual first; else numeric compare when both sides are numbers; else false |
10 == 10.0, 1 == '1' |
true, false |
!= |
negation of == |
10 != 10.0, '1' != 1 |
false, true |
Equality is the one place mixed types are allowed without error: they simply compare
unequal. A string is never numerically equal to a number (1 == '1' is false),
while two numbers of different kinds compare by value (10 == 10.0 is true).
Lists compare element-wise with these same rules, so scalar equality lifts into
them: [1] == [1.0] is true, ['1'] == [1] is false, and length mismatch
short-circuits to false. Other composites (maps, structs) fall back to
reflect.DeepEqual. Self-referential data is safe (past a depth limit the comparison
falls back to DeepEqual, which handles cycles).
Both operands must be bool — there is no truthiness coercion. A non-bool operand
(string, number, nil) is a type error.
| Operator | Rule | Example | Example result |
|---|---|---|---|
&& |
if LHS is false, RHS is not evaluated |
false && (1/0) |
false (no error) |
| ` | ` | if LHS is true, RHS is not evaluated |
|
| — | non-bool operand | 'x' && true, `1 |
| Operator | Rule | Example | Example result |
|---|---|---|---|
!x |
requires bool (no coercion) |
!true, !'x' |
false, error |
-x |
numbers only | -1, -1.5 |
int64(-1), -1.5 |
~x |
integers | ~0 |
int64(-1) |
| Operator | Rule | Example | Example result |
|---|---|---|---|
| `& | ^` | integers, both int-like | 5 & 3, 5 ^ 1 |
<< >> |
integers, shift count >= 0 |
1 << 3, 1 << -1 |
int64(8), error |
| Syntax | Rule | Example | Example result |
|---|---|---|---|
a ? b : c |
condition must be bool (no coercion); evaluates only one branch |
true ? 1 : 2 |
int64(1) |
Engine.Eval parses on every call. To evaluate the same expression repeatedly (the common rules-engine pattern), compile it once into a Program and reuse it:
prog, err := e.Compile("user.VIP && amount > 100 ? amount * 0.8 : amount")
if err != nil {
// handle parse error
}
for _, order := range orders {
v, err := prog.Eval(order) // no re-parsing
_ = v
_ = err
}A Program is an immutable, self-contained artifact: the functions, macros,
strict flag, and method filter in effect at Compile time are snapshotted into it.
Changing the Engine afterwards (RegisterFunc, SetStrict, SetMethodFilter, …) does
not affect Programs already compiled — they stay reproducible and are safe to
evaluate concurrently. To pick up new configuration, recompile.
At compile time, sub-expressions built entirely from literals are constant-folded
(e.g. 1 + 2 * 3 becomes the literal 7), so they are not recomputed on every Eval.
A subtree that errors when folded (like 1 / 0) is left intact so the error still
surfaces at eval time.
Program.EvalContext(ctx, data) is Eval with cooperative cancellation: evaluation
counts its work in steps (one per AST node visited and per element scanned by in)
and polls ctx roughly every 1024 steps, returning ctx.Err() once the context is
cancelled or its deadline passes. Use it when rules may scan large collections inside
a latency budget — a goroutine cannot be killed from outside, so this cooperative
check is the only way to actually stop a long evaluation rather than merely abandon
it.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
v, err := prog.EvalContext(ctx, data) // errors.Is(err, context.DeadlineExceeded) on timeoutMacros that copy the Context (child := ctx) inherit the cancellation state, so
userland collection loops stay cancellable too. Plain Eval skips all of this and
pays no overhead.
prog.Vars() []string— the distinct root variable identifiers the program reads (the base of each access chain, souser.Agereportsuser, not the full pathuser.Age). Useful for validating which top-level objects a rule needs, or building dependency indexes, before running it.prog.Funcs() []string— the distinct function and method names the program calls (bare calls likecontains(...)and method calls likeuser.Save()).
Macro caveat: macro arguments are collected like any other expression. A macro
that re-roots its arguments — e.g. a collection predicate evaluated per element, so in
any(orders, price > 100) the price is element-relative — makes those identifiers
not root variables, yet Vars() still reports them. Static analysis is inherently
unreliable inside macro arguments; treat Vars()/Funcs() as exact only for
macro-free expressions.
prog, _ := e.Compile("user.Age > 18 && contains(user.Name, 'a')")
prog.Vars() // ["user"]
prog.Funcs() // ["contains"]EvalTo[T] evaluates and converts the result to T. Converting a float result to an integer T truncates toward zero (e.g. EvalTo[int] of 1.9 yields 1).
Okra never crashes the host application — every public entry point (Eval, Compile, Program.Eval, EvalTo, ParseExpr) recovers panics and returns them as errors.
- If evaluation hits a
panic(from reflection/type conversions or inside user methods), Okra recovers and returns it as anerror. - Unexported struct fields are never exposed (reading them via reflection would panic). Like any unknown field,
obj.unexportedFieldis an error in strict mode (the default) and resolves tonilwhen strict is off — it is never leaked either way.
Several errors are exported so callers can match them with errors.Is, even though they are wrapped with context:
ErrDivByZero,ErrModByZero,ErrFloatModuloErrIntOverflow(checked integer arithmetic)ErrNegativeShiftErrNotFound(unknown function or method)ErrUnknownField(strict-mode missing field/key/index)ErrMethodDenied(blocked by the method filter)
Strict mode is on by default. A missing struct field, absent map key,
out-of-range index, or member/method access on nil is an error
(ErrUnknownField), so a misspelled field fails loudly on the first evaluation rather
than silently resolving to nil and steering the rule down the wrong branch:
e := okra.NewEngine() // strict by default
_, err := e.Eval("user.Naem", data) // errors.Is(err, ErrUnknownField)Express a genuinely optional member explicitly with has / get
instead of relying on silent nil:
has(user, 'Coupon') ? user.Coupon.Code : 'none'
get(scores, 'bonus', 0)
To restore the old lenient behavior (missing → nil), opt out per Engine:
e.SetStrict(false)There is no null literal in the language — you cannot write one, and the type
system has no null. But nil values do arrive from the host: a map that holds nil, a
method that returns nil, or get(obj, name, default). Okra's rule is nil may exist
but not be used:
- A
nilmay be the final result of an expression (handed back to your Go caller, which can deal with it) and may be consumed byhas/get(getturns a nil value into your default). - A
nilthat enters any operation — arithmetic, comparison, concatenation,in,len, a?:/&&/||/!condition, or a further member access — is an error, never a silent0/false. - The one exemption is equality:
==/!=always have an answer, for nil and for mixed types alike (they simply compare unequal, andnil == nilistrue).
This complements strict mode: missing is an error at access time; a present nil value is fine to pass around, but using it in an operation is an error.
An error born at an operator is annotated with that sub-expression's source form, once, at its birthplace — so in a long rule you see which part failed:
(user.Age > user.Name): invalid comparison between int64 and string
Sentinel matching with errors.Is works through the annotation.
Because member/method access uses reflection, an expression can call any exported method on the data object. To lock this down, install a filter — names for which it returns false are denied (ErrMethodDenied). This gates reflected calls into the data object: explicit method calls (user.Save()) and getter-style access alike. It does not gate built-in or RegisterFunc functions, nor the x.len() shortcut — control those by not registering them. nil (the default) allows all.
allowed := map[string]bool{"FullName": true, "Age": true}
e.SetMethodFilter(func(name string) bool { return allowed[name] })Parser recursion (and therefore AST and evaluation depth) is bounded to keep deeply nested input from exhausting the stack. The default limit is MaxStackDepth (256); override it per Engine with e.SetMaxNestingDepth(n). Exceeding it returns an expression nesting too deep error.
user.Age >= 18 && (user.Name != '' ? true : false)
(scores.1 == 'Gold' || scores.2 == 'Silver') ? 1 << 3 : 0
len(tags) > 0 ? tags[0] : 'none'