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 docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Two layers:

`types.Boxed` (`uint64`) is VM stack/global currency. Heap objects are `types.Value` referenced by `KindRef` in `Boxed`. See `value-representation.md`.

`types.Traceable` marks heap objects containing refs (`Array`, `Struct`); GC walks them via `Refs() []Ref`.
`types.Traceable` marks heap objects containing refs (`Array`, `Struct`, `HostObject`); GC walks them via `Refs() []Ref`. `types.Fielded` is the indexed field-access contract used by `STRUCT_GET`/`STRUCT_SET`, implemented by `*Struct` and `*HostObject`.

### `interp/`

Expand All @@ -101,6 +101,8 @@ Two layers:

`HostFunction` (`host.go`): wraps `func(i *Interpreter, params []Boxed) ([]Boxed, error)` as `types.Value`. Lives in constants, called by `CONST_GET` + `CALL`. Use `Interpreter.Marshal`/`Unmarshal` to convert Go values; Go `func` marshals to `HostFunction`, final `error` return propagated as host-call error. `WithMarshaler` replaces default reflection-based converter.

`HostObject` (`host.go`): wraps a Go value that carries methods or unexported fields. Implements `types.Fielded` so `STRUCT_GET`/`STRUCT_SET` dispatch through the same indexed-field protocol used by `*types.Struct`. Field reads/writes reflect against an internal addressable copy of the receiver through the interpreter's `Marshaler`; methods are pre-bound as `*HostFunction` values allocated on the VM heap.

### `asm/`

`Assembler`: low-level IR emission — allocate VRegs, emit instructions, declare ABI boundaries. No VM stack semantics.
Expand Down
38 changes: 37 additions & 1 deletion docs/host-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ v, err := vm.Marshal(myGoValue)
| `[]float64` | `F64Array` | no heap allocation |
| `[]T` (other) | `*Array` (ref) | elements heap-allocated if ref-typed |
| `map[K]V` | `*Map` (ref) | heap-allocated |
| `struct` | `*Struct` (ref) | exported fields only |
| `struct` (exported fields only, no methods) | `*Struct` (ref) | data-only snapshot |
| `struct` with methods or unexported fields | `*HostObject` (ref) | see Host Objects below |
| named scalar with methods (e.g. `type Celsius float64`) | `*HostObject` (ref) | data fields empty, methods bound |
| `*T` | same as `T`, or `Null` if nil | pointer dereferenced |
| `func(...)` | `*HostFunction` (ref) | see below |
| `types.Value` | passthrough | returned as-is |
Expand Down Expand Up @@ -147,6 +149,40 @@ add := func(a, b types.I32) types.I32 { return a + b }
fn, err := vm.Marshal(add)
```

### Host Objects

`*HostObject` surfaces a Go value to the VM with both **data fields** and **bound methods** behind the same indexed-field protocol used by `*Struct`. `STRUCT_GET` / `STRUCT_SET` dispatch through the `types.Fielded` interface so opcodes are unchanged.

```go
type Counter struct{ Count int32 }

func (c *Counter) Bump(n int32) int32 {
c.Count += n
return c.Count
}

v, _ := vm.Marshal(Counter{Count: 1})
ho := v.(*interp.HostObject)
// ho.Typ.Fields = [Count: I32, Bump: func(I32) I32]
```

**Routing rules:** `Marshal` routes a Go value to `*HostObject` when **either**:

- The type has methods on `T` or `*T`.
- The type is a struct with unexported fields (would lose info via `*Struct`).

**Field layout:**

- Exported data fields first, in declaration order. Only fields whose Go kind maps to a VM primitive (`bool`, `int*`, `uint*`, `float*`, `string`) or implements `types.Value` are exposed; others are skipped.
- Methods second. Methods whose name collides with an exported data field are skipped.

**Receiver semantics:**

- `Receiver` is an **addressable copy** of the marshaled Go value, owned by the `HostObject`. Pointer-receiver method calls mutate this copy.
- The caller's original Go value is not mutated by VM-side writes. Round-trip via `Unmarshal(ho, &dst)` to recover the current state into a new Go value.

**Field access:** every `Field` / `SetField` reflects against `Receiver` through the interpreter's injected `Marshaler`. Methods are pre-bound as `*HostFunction` values allocated on the VM heap at marshal time; they are retained for the lifetime of the `HostObject` via `Refs`.

### `Unmarshal`

`Unmarshal` converts a `types.Value` back to a Go value. Destination must be a non-nil pointer.
Expand Down
5 changes: 4 additions & 1 deletion docs/value-representation.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,16 @@ Heap objects implement `types.Value`.
| `types.Ref` | `KindRef` | `TypeRef` | `Value` |
| `types.String` | `KindRef` | `TypeString` | `Value` |
| `*types.Array` | `KindRef` | `*ArrayType` | `Value`, `Traceable` |
| `*types.Struct` | `KindRef` | `*StructType` | `Value`, `Traceable` |
| `*types.Struct` | `KindRef` | `*StructType` | `Value`, `Traceable`, `Fielded` |
| `*types.Map` | `KindRef` | `*MapType` | `Value`, `Traceable` |
| `*types.Function` | `KindRef` | `*FunctionType` | `Value` |
| `*interp.HostFunction` | `KindRef` | `*FunctionType` | `Value` |
| `*interp.HostObject` | `KindRef` | `*StructType` | `Value`, `Traceable`, `Fielded` |

`Traceable` exposes `Refs() []Ref` for GC graph traversal. Any heap object containing refs must implement `Traceable`.

`Fielded` exposes indexed `Field` / `SetField` / `Raw` / `SetRaw` plus `StructType()`; `STRUCT_GET` and `STRUCT_SET` dispatch through it so both VM-native structs and host-supplied objects use the same opcodes. See [host-integration.md](host-integration.md) for HostObject semantics.

## Unbox to Value

`types.Unbox(v Boxed) Value` converts boxed values to concrete `types.Value`:
Expand Down
153 changes: 140 additions & 13 deletions interp/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package interp

import (
"fmt"
"strings"
"reflect"

"github.com/siyul-park/minivm/types"
)
Expand All @@ -15,23 +15,150 @@ type HostFunction struct {
var _ types.Value = (*HostFunction)(nil)

func NewHostFunction(typ *types.FunctionType, fn func(i *Interpreter, params []types.Boxed) ([]types.Boxed, error)) *HostFunction {
return &HostFunction{
Typ: typ,
Fn: fn,
return &HostFunction{Typ: typ, Fn: fn}
}

func (f *HostFunction) Kind() types.Kind { return types.KindRef }
func (f *HostFunction) Type() types.Type { return f.Typ }

func (f *HostFunction) String() string {
return fmt.Sprintf("%s\n<native>", f.Typ)
}

// HostObject exposes a Go value to the VM with both data and methods.
// Receiver is an addressable copy of the marshaled Go value owned by the
// HostObject; reads and writes happen through reflect against this copy, and
// the original caller-side value is unaffected unless explicitly restored via
// Unmarshal. Methods are pre-bound as *HostFunction values allocated on the
// VM heap and retained via Refs.
//
// Field / Raw allocate a fresh heap entry per read for KindRef fields; this
// is safe inside STRUCT_GET (which retains immediately) but callers outside
// the opcode handler must take ownership themselves.
type HostObject struct {
Typ *types.StructType
Receiver reflect.Value // addressable pointer to the original Go value
slots []hostSlot
interp *Interpreter
}

// hostSlot maps a VM field index to either a Go struct field (field ≥ 0) or a
// bound method (field < 0; addr is the *HostFunction heap address).
type hostSlot struct {
field int
addr int
}

func (s hostSlot) isMethod() bool { return s.field < 0 }

var (
_ types.Value = (*HostObject)(nil)
_ types.Traceable = (*HostObject)(nil)
_ types.Fielded = (*HostObject)(nil)
)

func (h *HostObject) Kind() types.Kind { return types.KindRef }
func (h *HostObject) Type() types.Type { return h.Typ }
func (h *HostObject) StructType() *types.StructType { return h.Typ }

func (h *HostObject) String() string {
if !h.Receiver.IsValid() {
return "host<>"
}
return fmt.Sprintf("host<%s>", h.Receiver.Elem().Type())
}

func (f *HostFunction) Kind() types.Kind {
return types.KindRef
func (h *HostObject) Refs() []types.Ref {
refs := make([]types.Ref, 0, len(h.slots))
for _, s := range h.slots {
if s.isMethod() {
refs = append(refs, types.Ref(s.addr))
}
}
return refs
}

func (f *HostFunction) Type() types.Type {
return f.Typ
func (h *HostObject) Field(i int) types.Boxed {
s, _, ok := h.lookup(i)
if !ok {
return 0
}
if s.isMethod() {
return types.BoxRef(s.addr)
}
return h.marshal(h.Receiver.Elem().Field(s.field))
}

func (f *HostFunction) String() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s\n", f.Typ.String()))
sb.WriteString("<native>")
return sb.String()
func (h *HostObject) SetField(i int, val types.Boxed) {
s, _, ok := h.lookup(i)
if !ok || s.isMethod() {
return
}
h.unmarshal(val, h.Receiver.Elem().Field(s.field))
}

func (h *HostObject) Raw(i int) uint64 {
s, f, ok := h.lookup(i)
if !ok {
return 0
}
if s.isMethod() {
return uint64(types.BoxRef(s.addr))
}
rv := h.Receiver.Elem().Field(s.field)
if f.Kind == types.KindI64 {
if rv.CanInt() {
return uint64(rv.Int())
}
return rv.Uint()
}
return uint64(h.marshal(rv))
}

func (h *HostObject) SetRaw(i int, bits uint64) {
s, f, ok := h.lookup(i)
if !ok || s.isMethod() {
return
}
rv := h.Receiver.Elem().Field(s.field)
if f.Kind == types.KindI64 {
if !rv.CanSet() {
return
}
if rv.CanInt() {
rv.SetInt(int64(bits))
} else {
rv.SetUint(bits)
}
return
}
h.unmarshal(types.Boxed(bits), rv)
}

func (h *HostObject) lookup(i int) (hostSlot, types.StructField, bool) {
if i < 0 || i >= len(h.slots) {
return hostSlot{}, types.StructField{}, false
}
return h.slots[i], h.Typ.Fields[i], true
}

// marshal delegates to the interpreter's injected Marshaler so HostObject
// stays decoupled from the reflect-based default implementation. Errors
// propagate via panic — there is no return path on the Fielded contract,
// and the opcode handler converts panics to VM errors.
func (h *HostObject) marshal(rv reflect.Value) types.Boxed {
v, err := h.interp.Marshal(rv.Interface())
if err != nil {
panic(err)
}
return h.interp.box(v)
}

func (h *HostObject) unmarshal(val types.Boxed, rv reflect.Value) {
if !rv.CanAddr() {
return
}
if err := h.interp.Unmarshal(val, rv.Addr().Interface()); err != nil {
panic(err)
}
}
126 changes: 126 additions & 0 deletions interp/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import (
"github.com/stretchr/testify/require"
)

type hostCounter struct {
Count int32
}

func (c *hostCounter) Bump(n int32) int32 {
c.Count += n
return c.Count
}

type hostCelsius float64

func (c hostCelsius) Fahrenheit() hostCelsius {
return c*9/5 + 32
}

func TestNewHostFunction(t *testing.T) {
t.Run("kind and type", func(t *testing.T) {
typ := &types.FunctionType{
Expand Down Expand Up @@ -48,3 +63,114 @@ func TestNewHostFunction(t *testing.T) {
require.Equal(t, types.I32(10), v)
})
}

func TestHostObject(t *testing.T) {
t.Run("struct with method routes to HostObject", func(t *testing.T) {
i := New(program.New(nil))
defer i.Close()

got, err := i.Marshal(hostCounter{Count: 1})
require.NoError(t, err)

ho, ok := got.(*HostObject)
require.True(t, ok)
require.Equal(t, "Count", ho.Typ.Fields[0].Name)
require.Equal(t, "Bump", ho.Typ.Fields[1].Name)
require.Equal(t, types.BoxI32(1), ho.Field(0))
})

t.Run("method call mutates receiver via pointer", func(t *testing.T) {
i := New(program.New(nil))
defer i.Close()

got, err := i.Marshal(hostCounter{Count: 1})
require.NoError(t, err)
ho := got.(*HostObject)

methodSlot := -1
for idx, f := range ho.Typ.Fields {
if f.Name == "Bump" {
methodSlot = idx
break
}
}
require.NotEqual(t, -1, methodSlot)

boxed := ho.Field(methodSlot)
require.Equal(t, types.KindRef, boxed.Kind())
v, err := i.Load(boxed.Ref())
require.NoError(t, err)
fn, ok := v.(*HostFunction)
require.True(t, ok)

returns, err := fn.Fn(i, []types.Boxed{types.BoxI32(4)})
require.NoError(t, err)
require.Equal(t, []types.Boxed{types.BoxI32(5)}, returns)

require.Equal(t, types.BoxI32(5), ho.Field(0))
})

t.Run("non-struct named scalar with method", func(t *testing.T) {
i := New(program.New(nil))
defer i.Close()

got, err := i.Marshal(hostCelsius(100))
require.NoError(t, err)
ho, ok := got.(*HostObject)
require.True(t, ok)
require.Len(t, ho.Typ.Fields, 1)
require.Equal(t, "Fahrenheit", ho.Typ.Fields[0].Name)
})

t.Run("unmarshal recovers receiver", func(t *testing.T) {
i := New(program.New(nil))
defer i.Close()

src := hostCounter{Count: 7}
got, err := i.Marshal(src)
require.NoError(t, err)

var dst hostCounter
require.NoError(t, i.Unmarshal(got, &dst))
require.Equal(t, int32(7), dst.Count)
})

t.Run("method name shadowed by field", func(t *testing.T) {
type shadow struct {
Bump int32
}
i := New(program.New(nil))
defer i.Close()

// shadow has no methods so it would normally take the *Struct path;
// embed it via a wrapper that adds a Bump method to force HostObject.
type wrapper struct {
Bump int32
tag int32
}
got, err := i.Marshal(wrapper{Bump: 9})
require.NoError(t, err)
ho, ok := got.(*HostObject)
require.True(t, ok)
// Only the data field named Bump is exposed; no duplicate.
count := 0
for _, f := range ho.Typ.Fields {
if f.Name == "Bump" {
count++
}
}
require.Equal(t, 1, count)
})

t.Run("SetField writes back to receiver", func(t *testing.T) {
i := New(program.New(nil))
defer i.Close()

got, err := i.Marshal(hostCounter{Count: 1})
require.NoError(t, err)
ho := got.(*HostObject)

ho.SetField(0, types.BoxI32(42))
require.Equal(t, types.BoxI32(42), ho.Field(0))
})
}
Loading
Loading