Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion boundaries1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1332,7 +1332,9 @@ func TestEdgeCase_TruthinessHonorsTypedNilAndNamedScalars(t *testing.T) {
{"bool(nonzeroInt)", true},
{"zeroInt + 2", int64(2)},
{`bool(emptyStr)`, false},
{`bool(falseStr)`, false},
// bool() does not inspect string content; "false" is a non-empty
// string and is therefore truthy.
{`bool(falseStr)`, true},
{`int(numStr) + 1`, int64(42)},
{`upper(falseStr)`, "FALSE"},
{`contains(falseStr, needle)`, true},
Expand Down
11 changes: 6 additions & 5 deletions boundaries2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,16 @@ type mystr string
type mybool bool
type myint int

// These three tests pin the (correct) behavior that IsTruthy resolves
// typed primitives via the reflect fallback. I initially suspected this
// was broken; verifying it isn't is still useful as a regression guard.
// These tests pin the (correct) behavior that IsTruthy resolves
// typed primitives via the reflect fallback.
func TestAdversarial_TruthyTypedString(t *testing.T) {
if IsTruthy(mystr("")) {
t.Fatalf("IsTruthy(mystr(\"\")) should be false, got true")
}
if IsTruthy(mystr("false")) {
t.Fatalf("IsTruthy(mystr(\"false\")) should be false (matches plain string rule), got true")
// Non-empty strings are truthy regardless of content. The string
// "false" is just a five-character string.
if !IsTruthy(mystr("false")) {
t.Fatalf("IsTruthy(mystr(\"false\")) should be true (non-empty string), got false")
}
if !IsTruthy(mystr("hello")) {
t.Fatalf("IsTruthy(mystr(\"hello\")) should be true")
Expand Down
23 changes: 17 additions & 6 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,20 @@ group expressions as usual. Go's bitwise operators (`&`, `|`, `^`, `<<`,

### Logical (`&& ||`)

- Short-circuit. `false && X` evaluates to `false` without evaluating
`X`; `true || X` evaluates to `true` without evaluating `X`.
- Both operands are run through [truthiness](#truthiness) rules first,
so `"x" && 1` is `true`.
- The result type is always `bool`.
- Short-circuit. `falsey && X` does not evaluate `X`; `truthy || X`
does not evaluate `X`. Whether an operand is "truthy" is decided by
the [truthiness](#truthiness) rules.
- The result is the **deciding operand**, not a coerced bool. This
matches Python `and`/`or`, JavaScript `&&`/`||`, Lua, and Ruby:

```
x || y // x if truthy, else y
x && y // x if falsey, else y
```

So `"ada" || "(none)"` is `"ada"`, `"" || "(none)"` is `"(none)"`,
and `count || 0` falls back to `0` only when `count` is falsey.
Where a strict bool is required, wrap with `bool(...)`.

### Unary

Expand Down Expand Up @@ -132,7 +141,9 @@ which treats these as **falsey**:
- Empty slice, array, or map
- A nil channel, function, interface, map, pointer, or slice

Everything else is truthy.
Everything else is truthy. String content is not inspected:
`bool("false")` is `true` because the string is non-empty. Callers who
need to parse boolean strings should do so explicitly.

## Identifier resolution

Expand Down
40 changes: 38 additions & 2 deletions engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,53 @@ func TestEval_ShortCircuit(t *testing.T) {
env := map[string]any{
"exploder": func() bool { panic("should not be called") },
}
// && short-circuits when lhs is false
// && short-circuits when lhs is falsey
got, err := evalExpr(t.Context(), "false && exploder()", env)
require.NoError(t, err)
require.Equal(t, false, got)

// || short-circuits when lhs is true
// || short-circuits when lhs is truthy
got, err = evalExpr(t.Context(), "true || exploder()", env)
require.NoError(t, err)
require.Equal(t, true, got)
}

// `&&` and `||` return the deciding operand (Python `and`/`or` semantics),
// not a coerced bool. This composes with truthiness for idioms like
// `name || "(none)"` and `xs || []`.
func TestEval_LogicalReturnsOperand(t *testing.T) {
cases := []struct {
expr string
env map[string]any
want any
}{
// || returns lhs when lhs is truthy, else rhs.
{`"ada" || "(none)"`, nil, "ada"},
{`"" || "(none)"`, nil, "(none)"},
{`nil || "(none)"`, nil, "(none)"},
{`0 || 42`, nil, int64(42)},
{`name || "(none)"`, map[string]any{"name": "Ada"}, "Ada"},
{`name || "(none)"`, map[string]any{"name": ""}, "(none)"},
{`name || "(none)"`, map[string]any{"name": nil}, "(none)"},
{`count || 0`, map[string]any{"count": int64(5)}, int64(5)},
{`count || 0`, map[string]any{"count": int64(0)}, int64(0)},

// && returns lhs when lhs is falsey, else rhs.
{`"a" && "b"`, nil, "b"},
{`"" && "b"`, nil, ""},
{`nil && "b"`, nil, nil},
{`0 && 1`, nil, int64(0)},
{`1 && 2`, nil, int64(2)},
}
for _, tc := range cases {
t.Run(tc.expr, func(t *testing.T) {
got, err := evalExpr(t.Context(), tc.expr, tc.env)
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}

func TestEval_Selectors(t *testing.T) {
env := map[string]any{
"state": map[string]any{
Expand Down
11 changes: 7 additions & 4 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,13 @@ See `docs/guides/examples.md` for worked multi-line expressions.
errors, not panics.
- **Truthiness:** nil, false, zero numbers, empty string, empty
slice/array/map, and typed-nil nilable kinds are falsey. Everything
else is truthy.
- **Short-circuit:** `&&` / `||` short-circuit on truthiness. `cond && "msg"`
returns the string when cond is true, which is the expr idiom for
"emit this value if the condition holds."
else is truthy. String content is not inspected: `bool("false")` is
`true`.
- **`&&` / `||` return operands**, not coerced bools (Python `and`/`or`
semantics). `x || y` is `x` when `x` is truthy, else `y`; `x && y` is
`x` when `x` is falsey, else `y`. Common idioms: `name || "(none)"`,
`xs || []`, `count || 0`. Wrap with `bool(...)` when you need a
strict bool.
- **String indexing** counts runes, matching `len` on strings.
- **Selectors and index errors:** missing map keys, missing struct
fields, nil selectors, and out-of-bounds indexes all return
Expand Down
13 changes: 6 additions & 7 deletions program.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,23 +489,22 @@ func (p *Program) evalUnary(ctx context.Context, n *ast.UnaryExpr, env any, dept
func (p *Program) evalBinary(ctx context.Context, n *ast.BinaryExpr, env any, depth int) (any, error) {
// Short-circuit logical operators: right-hand side is not evaluated
// when the left-hand side is sufficient to determine the result.
// Both operators return the deciding operand (matching Python
// `and`/`or`, JS `||`/`&&`), not a coerced bool. This composes with
// truthiness for idioms like `name || "(none)"` and `xs || []`.
if n.Op == token.LAND || n.Op == token.LOR {
lhs, err := p.eval(ctx, n.X, env, depth)
if err != nil {
return nil, err
}
lt := isTruthy(lhs)
if n.Op == token.LAND && !lt {
return false, nil
return lhs, nil
}
if n.Op == token.LOR && lt {
return true, nil
return lhs, nil
}
rhs, err := p.eval(ctx, n.Y, env, depth)
if err != nil {
return nil, err
}
return isTruthy(rhs), nil
return p.eval(ctx, n.Y, env, depth)
}

lhs, err := p.eval(ctx, n.X, env, depth)
Expand Down
4 changes: 2 additions & 2 deletions script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ func TestIsTruthy(t *testing.T) {
{"zero float64", 0.0, false},
{"nonempty string", "hello", true},
{"empty string", "", false},
{"false string lowercase", "false", false},
{"false string mixed case", "FaLsE", false},
{"false string lowercase", "false", true},
{"false string mixed case", "FaLsE", true},
{"nonempty []any", []any{1}, true},
{"empty []any", []any{}, false},
{"nonempty map", map[string]any{"a": 1}, true},
Expand Down
24 changes: 12 additions & 12 deletions truthy.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package expr

import (
"reflect"
"strings"
)
import "reflect"

// IsTruthy reports whether a Go value should be treated as truthy in
// expr conditionals. The rules are:
//
// - nil: false
// - bool: itself
// - numeric: non-zero is truthy
// - string: non-empty and not "false" (case-insensitive)
// - slices/arrays/maps: non-empty is truthy
// - nil: false
// - anything else: non-nil is truthy
// - string: non-empty
// - slices, arrays, maps: non-empty is truthy
// - chan, func, interface, pointer: non-nil is truthy
// - anything else: truthy
//
// This is also exposed as the bool() builtin.
// This is also exposed as the bool() builtin. Note that string content
// is not inspected; bool("false") is true because the string is
// non-empty. Callers who need to parse boolean strings should do so
// explicitly.
func IsTruthy(value any) bool {
switch v := value.(type) {
case nil:
Expand Down Expand Up @@ -47,7 +48,7 @@ func IsTruthy(value any) bool {
case float64:
return v != 0
case string:
return v != "" && !strings.EqualFold(v, "false")
return v != ""
}
rv := reflect.ValueOf(value)
switch rv.Kind() {
Expand All @@ -60,8 +61,7 @@ func IsTruthy(value any) bool {
case reflect.Float32, reflect.Float64:
return rv.Float() != 0
case reflect.String:
s := rv.String()
return s != "" && !strings.EqualFold(s, "false")
return rv.String() != ""
case reflect.Slice, reflect.Array, reflect.Map:
return rv.Len() > 0
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Pointer:
Expand Down
Loading