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
26 changes: 21 additions & 5 deletions boundaries2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -661,14 +661,30 @@ func TestAdversarial_NegativeIndex(t *testing.T) {
require.Error(t, err)
}

// --- 39. Map index with int idx but the idx is float64 (1.0) ---
// --- 39. Slice index with float64 ---

// Integer-valued floats (1.0) are accepted as indices, matching the
// "ints and floats are fungible when integral" rule used by
// arithmetic. JSON-derived numbers arrive as float64 so accepting
// them removes a CLI papercut. Non-integer floats still error.
func TestAdversarial_FloatIndexOnSlice(t *testing.T) {
env := map[string]any{"xs": []any{int64(1), int64(2), int64(3)}}
env := map[string]any{"xs": []any{int64(10), int64(20), int64(30)}}

got, err := evalExpr(t.Context(), "xs[1.0]", env)
t.Logf("xs[1.0] => %v, err=%v", got, err)
// toInt64 doesn't accept float; should error rather than silently work.
require.Error(t, err)
require.NoError(t, err)
require.Equal(t, int64(20), got)

_, err = evalExpr(t.Context(), "xs[1.5]", env)
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), "index must be integer")

got, err = evalExpr(t.Context(), `s[2.0]`, map[string]any{"s": "hello"})
require.NoError(t, err)
require.Equal(t, "l", got)

_, err = evalExpr(t.Context(), `s[2.5]`, map[string]any{"s": "hello"})
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), "index must be integer")
}

// --- 40. Nested template inside composite literal ---
Expand Down
10 changes: 6 additions & 4 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,14 @@ limited by [evaluation depth](#limits-and-safety).
- `map[string]any`: `i` must be a string. Missing key → `ErrEvaluate`.
- Other maps: `i` is converted to the map's key type if assignable or
convertible. A nil index on a typed map is an error (not a panic).
- Slice, array: `i` must be an integer. Negative indices and indices
`>= len(x)` return `ErrEvaluate` (expr does not support Python-style
negative indexing).
- Slice, array: `i` must be an integer or an integer-valued float
(`xs[1.0]` works, `xs[1.5]` is an error). Negative indices and
indices `>= len(x)` return `ErrEvaluate` (expr does not support
Python-style negative indexing).
- String: `i` selects the `i`-th **rune** (Unicode code point) and
returns it as a one-rune string. `len(s)` is also in runes, so indexing
and length stay consistent for non-ASCII strings.
and length stay consistent for non-ASCII strings. Integer-valued
floats are accepted here as well.
- Anything else → `ErrEvaluate`.

Slice expressions (`x[a:b]`), full slices (`x[a:b:c]`), and type
Expand Down
4 changes: 4 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ See `docs/guides/examples.md` for worked multi-line expressions.
`xs || []`, `count || 0`. Wrap with `bool(...)` when you need a
strict bool.
- **String indexing** counts runes, matching `len` on strings.
- **Slice / string indices** accept integer-valued floats (`xs[1.0]`
works, `xs[1.5]` errors), matching the "ints and floats fungible
when integral" arithmetic rule. JSON-derived indices arrive as
float64, so this removes a CLI papercut.
- **Selectors and index errors:** missing map keys, missing struct
fields, nil selectors, and out-of-bounds indexes all return
`ErrEvaluate` — expr never panics on well-formed env input.
Expand Down
12 changes: 6 additions & 6 deletions program.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,18 +994,18 @@ func indexValue(recv, idx any) (any, error) {
rv := reflect.ValueOf(recv)
switch rv.Kind() {
case reflect.Slice, reflect.Array:
i, ok := toInt64(idx)
if !ok {
return nil, fmt.Errorf("%w: index must be integer, got %T", ErrEvaluate, idx)
i, err := toIndexInt(idx)
if err != nil {
return nil, err
}
if i < 0 || i >= int64(rv.Len()) {
return nil, fmt.Errorf("%w: index %d out of range [0, %d)", ErrEvaluate, i, rv.Len())
}
return rv.Index(int(i)).Interface(), nil
case reflect.String:
i, ok := toInt64(idx)
if !ok {
return nil, fmt.Errorf("%w: index must be integer, got %T", ErrEvaluate, idx)
i, err := toIndexInt(idx)
if err != nil {
return nil, err
}
runes := []rune(rv.String())
if i < 0 || i >= int64(len(runes)) {
Expand Down
26 changes: 26 additions & 0 deletions reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,32 @@ func truncFloatToInt64(f float64, target string) (int64, error) {
return int64(t), nil
}

// toIndexInt converts an evaluated index value to int64. Integers of
// any kind pass through. Floats are accepted only when their value is
// finite and exactly integral, matching expr's "ints and floats are
// fungible when integral" arithmetic rule. Non-integer or non-numeric
// indices return an ErrEvaluate-wrapped error suitable for surfacing
// directly to callers.
func toIndexInt(v any) (int64, error) {
if i, ok := toInt64(v); ok {
return i, nil
}
if f, ok := toFloat64(v); ok {
if math.IsNaN(f) || math.IsInf(f, 0) {
return 0, fmt.Errorf("%w: index must be integer, got %v", ErrEvaluate, f)
}
t := math.Trunc(f)
if t != f {
return 0, fmt.Errorf("%w: index must be integer, got %v", ErrEvaluate, f)
}
if t >= int64LimitFloat || t < -int64LimitFloat {
return 0, fmt.Errorf("%w: index %v out of int64 range", ErrEvaluate, f)
}
return int64(t), nil
}
return 0, fmt.Errorf("%w: index must be integer, got %T", ErrEvaluate, v)
}

func mapStringKey(keyType reflect.Type, key string) reflect.Value {
kv := reflect.ValueOf(key)
if kv.Type().AssignableTo(keyType) {
Expand Down
Loading