diff --git a/boundaries2_test.go b/boundaries2_test.go index d614912..14f63a1 100644 --- a/boundaries2_test.go +++ b/boundaries2_test.go @@ -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 --- diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 7914e59..6c2df2a 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -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 diff --git a/llms.txt b/llms.txt index 2646e53..b09f2bb 100644 --- a/llms.txt +++ b/llms.txt @@ -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. diff --git a/program.go b/program.go index f474502..cf678f6 100644 --- a/program.go +++ b/program.go @@ -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)) { diff --git a/reflect.go b/reflect.go index baf49a8..6e15cb9 100644 --- a/reflect.go +++ b/reflect.go @@ -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) {