From 109be33d1495872796dc225f4fb3eecdeed5ccdb Mon Sep 17 00:00:00 2001
From: Leon Mika <lmika@lmika.org>
Date: Sat, 17 May 2025 10:34:39 +1000
Subject: [PATCH] Made Hashable public and bindable

---
 ucl/builtins.go         | 18 ++++++++---------
 ucl/objs.go             |  2 +-
 ucl/userbuiltin.go      |  7 +++++++
 ucl/userbuiltin_test.go | 43 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 60 insertions(+), 10 deletions(-)

diff --git a/ucl/builtins.go b/ucl/builtins.go
index f6ad77f..56c8f56 100644
--- a/ucl/builtins.go
+++ b/ucl/builtins.go
@@ -354,8 +354,8 @@ func objectsEqual(l, r Object) bool {
 			}
 		}
 		return true
-	case hashable:
-		rv, ok := r.(hashable)
+	case Hashable:
+		rv, ok := r.(Hashable)
 		if !ok {
 			return false
 		}
@@ -469,7 +469,7 @@ func lenBuiltin(ctx context.Context, args invocationArgs) (Object, error) {
 		return IntObject(len(string(v))), nil
 	case Listable:
 		return IntObject(v.Len()), nil
-	case hashable:
+	case Hashable:
 		return IntObject(v.Len()), nil
 	case Iterable:
 		cnt := 0
@@ -497,10 +497,10 @@ func indexLookup(ctx context.Context, obj, elem Object) (Object, error) {
 			return v.Index(int(intIdx)), nil
 		}
 		return nil, nil
-	case hashable:
+	case Hashable:
 		strIdx, ok := elem.(StringObject)
 		if !ok {
-			return nil, errors.New("expected string for hashable")
+			return nil, errors.New("expected string for Hashable")
 		}
 		return v.Value(string(strIdx)), nil
 	}
@@ -531,7 +531,7 @@ func keysBuiltin(ctx context.Context, args invocationArgs) (Object, error) {
 
 	val := args.args[0]
 	switch v := val.(type) {
-	case hashable:
+	case Hashable:
 		keys := make(ListObject, 0, v.Len())
 		if err := v.Each(func(k string, _ Object) error {
 			keys = append(keys, StringObject(k))
@@ -676,7 +676,7 @@ func filterBuiltin(ctx context.Context, args invocationArgs) (Object, error) {
 			}
 		}
 		return &newList, nil
-	case hashable:
+	case Hashable:
 		newHash := hashObject{}
 		if err := t.Each(func(k string, v Object) error {
 			if m, err := inv.invoke(ctx, args.fork([]Object{StringObject(k), v})); err != nil {
@@ -741,7 +741,7 @@ func reduceBuiltin(ctx context.Context, args invocationArgs) (Object, error) {
 			accum = newAccum
 		}
 		return accum, nil
-	case hashable:
+	case Hashable:
 		// TODO: should raise error?
 		if err := t.Each(func(k string, v Object) error {
 			newAccum, err := block.invoke(ctx, args.fork([]Object{StringObject(k), v, accum}))
@@ -942,7 +942,7 @@ func foreachBuiltin(ctx context.Context, args macroArgs) (Object, error) {
 				}
 			}
 		}
-	case hashable:
+	case Hashable:
 		err := t.Each(func(k string, v Object) error {
 			last, err = args.evalBlock(ctx, blockIdx, []Object{StringObject(k), v}, false)
 			return err
diff --git a/ucl/objs.go b/ucl/objs.go
index 5dbf158..b4b0c2a 100644
--- a/ucl/objs.go
+++ b/ucl/objs.go
@@ -40,7 +40,7 @@ type ModListable interface {
 	Insert(idx int, obj Object) error
 }
 
-type hashable interface {
+type Hashable interface {
 	Len() int
 	Value(k string) Object
 	Each(func(k string, v Object) error) error
diff --git a/ucl/userbuiltin.go b/ucl/userbuiltin.go
index ad39095..e38a2f8 100644
--- a/ucl/userbuiltin.go
+++ b/ucl/userbuiltin.go
@@ -127,6 +127,13 @@ func (ca CallArgs) bindArg(v interface{}, arg Object) error {
 			return nil
 		}
 		return errors.New("exepected listable")
+	case *Hashable:
+		i, ok := arg.(Hashable)
+		if !ok {
+			return errors.New("exepected hashable")
+		}
+		*t = i
+		return nil
 	case *Iterable:
 		if i, ok := arg.(Iterable); ok {
 			*t = i
diff --git a/ucl/userbuiltin_test.go b/ucl/userbuiltin_test.go
index 5a0e1c0..9c631e7 100644
--- a/ucl/userbuiltin_test.go
+++ b/ucl/userbuiltin_test.go
@@ -301,6 +301,49 @@ func TestCallArgs_CanBind(t *testing.T) {
 		})
 	}
 
+	t.Run("can bind Hashable", func(t *testing.T) {
+		tests := []struct {
+			descr   string
+			eval    string
+			want    any
+			wantErr bool
+		}{
+			{descr: "return key 1", eval: `keyval [a:"hello" b:"world"] "a"`, want: "hello"},
+			{descr: "return key 2", eval: `keyval [a:"hello" b:"world"] "b"`, want: "world"},
+			{descr: "return key 3", eval: `keyval (keyval [a:"hello" b:[c:"fla"]] "b") c`, want: "fla"},
+
+			{descr: "err 1", eval: `keyval not-a-hashable "b"`, wantErr: true},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.descr, func(t *testing.T) {
+				ctx := context.Background()
+
+				inst := ucl.New()
+				inst.SetBuiltin("keyval", func(ctx context.Context, args ucl.CallArgs) (any, error) {
+					var (
+						h ucl.Hashable
+						k string
+					)
+					if err := args.Bind(&h, &k); err != nil {
+						return nil, err
+					}
+
+					return h.Value(k), nil
+				})
+
+				res, err := inst.Eval(ctx, tt.eval)
+				if tt.wantErr {
+					assert.Error(t, err)
+					assert.Nil(t, res)
+				} else {
+					assert.NoError(t, err)
+					assert.Equal(t, tt.want, res)
+				}
+			})
+		}
+	})
+
 	t.Run("can bind invokable", func(t *testing.T) {
 		inst := ucl.New()
 		inst.SetBuiltin("toUpper", func(ctx context.Context, args ucl.CallArgs) (any, error) {