From ac1661fe199d3d61af65d8ad2e18304a3412ea42 Mon Sep 17 00:00:00 2001 From: Jen Basch Date: Wed, 27 May 2026 12:41:41 -0700 Subject: [PATCH] SPICE-0020: Type-safe deferred references --- codegen/src/gen.pkl | 2 +- codegen/src/internal/typegen.pkl | 21 ++++++++ pkl/decode_struct.go | 27 ++++++++++ pkl/decoder.go | 9 ++++ pkl/test_fixtures/gen/reference/A.pkl.go | 10 ++++ pkl/test_fixtures/gen/reference/D.pkl.go | 10 ++++ pkl/test_fixtures/gen/reference/MapKey.pkl.go | 6 +++ .../gen/reference/Reference.pkl.go | 43 +++++++++++++++ pkl/test_fixtures/gen/reference/init.pkl.go | 11 ++++ .../msgpack/reference.pkl.msgpack | 1 + pkl/test_fixtures/reference.pkl | 25 +++++++++ pkl/unmarshal_test.go | 52 +++++++++++++++++++ pkl/values.go | 35 ++++++++++++- 13 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 pkl/test_fixtures/gen/reference/A.pkl.go create mode 100644 pkl/test_fixtures/gen/reference/D.pkl.go create mode 100644 pkl/test_fixtures/gen/reference/MapKey.pkl.go create mode 100644 pkl/test_fixtures/gen/reference/Reference.pkl.go create mode 100644 pkl/test_fixtures/gen/reference/init.pkl.go create mode 100644 pkl/test_fixtures/msgpack/reference.pkl.msgpack create mode 100644 pkl/test_fixtures/reference.pkl diff --git a/codegen/src/gen.pkl b/codegen/src/gen.pkl index ec9140f1..b0922d9f 100644 --- a/codegen/src/gen.pkl +++ b/codegen/src/gen.pkl @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -@ModuleInfo { minPklVersion = "0.31.0" } +@ModuleInfo { minPklVersion = "0.32.0" } module pkl.go.gen extends "pkl:Command" diff --git a/codegen/src/internal/typegen.pkl b/codegen/src/internal/typegen.pkl index 0c8eb1dd..b415f935 100644 --- a/codegen/src/internal/typegen.pkl +++ b/codegen/src/internal/typegen.pkl @@ -18,6 +18,7 @@ @Unlisted module pkl.golang.internal.typegen +import "pkl:ref" import "pkl:reflect" import "GoMapping.pkl" @@ -84,6 +85,8 @@ function generateDeclaredType( generateSet(type, enclosing, seenMappings) else if (reflectee == Pair) generatePair(type, enclosing, seenMappings) + else if (reflectee == ref.Reference) + generateReference(type, enclosing, seenMappings) else throw("Cannot generate type \(type.referent.name) as Go.") @@ -147,6 +150,18 @@ function generatePair( typeArguments = type.typeArguments.map((t) -> generateType(t, enclosing, seenMappings)) } +function generateReference( + type: reflect.DeclaredType, + enclosing: reflect.TypeDeclaration, + seenMappings: List, +): Type = new Type.Declared { + typeName = "Reference" + package = "pkl" + importPath = "github.com/apple/pkl-go/pkl" + // only domain becomes a type argument in go + typeArguments = List(generateType(type.typeArguments.first, enclosing, seenMappings)) +} + local function builtInType(typ: String): Type.Declared = new { typeName = typ } local anyType: Type.Declared = builtInType("any") @@ -205,4 +220,10 @@ mappedTypes: Mapping = new { [Bytes] = new Type.Slice { elem = new Type.Declared { typeName = "byte" } } + // introduced in Pkl 0.32 + [ref.Access] = new Type.Declared { + package = "pkl" + typeName = "ReferenceAccess" + importPath = "github.com/apple/pkl-go/pkl" + } } diff --git a/pkl/decode_struct.go b/pkl/decode_struct.go index 1ea94828..dbda0a03 100644 --- a/pkl/decode_struct.go +++ b/pkl/decode_struct.go @@ -321,6 +321,33 @@ func (d *decoder) decodeTypeAlias(length int) (*reflect.Value, error) { return &ret, nil } +func (d *decoder) decodeReference(typ reflect.Type) (*reflect.Value, error) { + ret := reflect.New(typ).Elem() + + domainField := ret.FieldByName("Domain") + domain, err := d.Decode(domainField.Type()) + if err != nil { + return nil, err + } + domainField.Set(*domain) + + dataField := ret.FieldByName("Data") + data, err := d.Decode(dataField.Type()) + if err != nil { + return nil, err + } + dataField.Set(*data) + + pathField := ret.FieldByName("Path") + path, err := d.decodeSliceImpl(pathField.Type()) + if err != nil { + return nil, err + } + pathField.Set(*path) + + return &ret, nil +} + func parseStructOpts(field *reflect.StructField) structFieldOpts { ret := structFieldOpts{propertyName: field.Name} tagValue, exists := field.Tag.Lookup(StructTag) diff --git a/pkl/decoder.go b/pkl/decoder.go index a7890e57..c9063a3c 100644 --- a/pkl/decoder.go +++ b/pkl/decoder.go @@ -44,6 +44,7 @@ const ( codeTypeAlias = 0x0D codeFunction = 0x0E codeBytes = 0x0F + codeReference = 0x20 codeObjectMemberProperty = 0x10 codeObjectMemberEntry = 0x11 @@ -242,6 +243,12 @@ func (d *decoder) decodePklObject(typ reflect.Type, requireStruct bool) (res *re res, err = d.decodeClass(length) case code == codeTypeAlias: res, err = d.decodeTypeAlias(length) + case code == codeReference: + if typ == emptyInterfaceType { + res, err = d.decodeReference(reflect.TypeFor[Reference[any]]()) + } else { + res, err = d.decodeReference(typ) + } default: if requireStruct { return nil, fmt.Errorf("code %#02x cannot be decoded into a struct", code) @@ -291,6 +298,8 @@ func getDecodedLength(code, length int) int { } // before pkl 0.30 only the type code is present return 0 + case codeReference: + return 3 // domain, data, path default: return 1 } diff --git a/pkl/test_fixtures/gen/reference/A.pkl.go b/pkl/test_fixtures/gen/reference/A.pkl.go new file mode 100644 index 00000000..e6ee996d --- /dev/null +++ b/pkl/test_fixtures/gen/reference/A.pkl.go @@ -0,0 +1,10 @@ +// Code generated from Pkl module `reference`. DO NOT EDIT. +package reference + +type A struct { + Foo int `pkl:"foo"` + + Bar map[*string]int `pkl:"bar"` + + Baz map[MapKey]string `pkl:"baz"` +} diff --git a/pkl/test_fixtures/gen/reference/D.pkl.go b/pkl/test_fixtures/gen/reference/D.pkl.go new file mode 100644 index 00000000..9983539e --- /dev/null +++ b/pkl/test_fixtures/gen/reference/D.pkl.go @@ -0,0 +1,10 @@ +// Code generated from Pkl module `reference`. DO NOT EDIT. +package reference + +type D interface { +} + +var _ D = DImpl{} + +type DImpl struct { +} diff --git a/pkl/test_fixtures/gen/reference/MapKey.pkl.go b/pkl/test_fixtures/gen/reference/MapKey.pkl.go new file mode 100644 index 00000000..69c15527 --- /dev/null +++ b/pkl/test_fixtures/gen/reference/MapKey.pkl.go @@ -0,0 +1,6 @@ +// Code generated from Pkl module `reference`. DO NOT EDIT. +package reference + +type MapKey struct { + Key string `pkl:"key"` +} diff --git a/pkl/test_fixtures/gen/reference/Reference.pkl.go b/pkl/test_fixtures/gen/reference/Reference.pkl.go new file mode 100644 index 00000000..a3c25437 --- /dev/null +++ b/pkl/test_fixtures/gen/reference/Reference.pkl.go @@ -0,0 +1,43 @@ +// Code generated from Pkl module `reference`. DO NOT EDIT. +package reference + +import ( + "context" + + "github.com/apple/pkl-go/pkl" +) + +type Reference struct { + Res0 pkl.Reference[D] `pkl:"res0"` + + Res1 pkl.Reference[D] `pkl:"res1"` + + Res2 pkl.Reference[D] `pkl:"res2"` + + Res3 pkl.Reference[D] `pkl:"res3"` + + Res4 pkl.Reference[D] `pkl:"res4"` +} + +// LoadFromPath loads the pkl module at the given path and evaluates it into a Reference +func LoadFromPath(ctx context.Context, path string) (ret Reference, err error) { + evaluator, err := pkl.NewEvaluator(ctx, pkl.PreconfiguredOptions) + if err != nil { + return ret, err + } + defer func() { + cerr := evaluator.Close() + if err == nil { + err = cerr + } + }() + ret, err = Load(ctx, evaluator, pkl.FileSource(path)) + return ret, err +} + +// Load loads the pkl module at the given source and evaluates it with the given evaluator into a Reference +func Load(ctx context.Context, evaluator pkl.Evaluator, source *pkl.ModuleSource) (Reference, error) { + var ret Reference + err := evaluator.EvaluateModule(ctx, source, &ret) + return ret, err +} diff --git a/pkl/test_fixtures/gen/reference/init.pkl.go b/pkl/test_fixtures/gen/reference/init.pkl.go new file mode 100644 index 00000000..3d8fe92c --- /dev/null +++ b/pkl/test_fixtures/gen/reference/init.pkl.go @@ -0,0 +1,11 @@ +// Code generated from Pkl module `reference`. DO NOT EDIT. +package reference + +import "github.com/apple/pkl-go/pkl" + +func init() { + pkl.RegisterMappingFor[Reference]("reference") + pkl.RegisterMappingFor[A]("reference#A") + pkl.RegisterMappingFor[DImpl]("reference#D") + pkl.RegisterMappingFor[MapKey]("reference#MapKey") +} diff --git a/pkl/test_fixtures/msgpack/reference.pkl.msgpack b/pkl/test_fixtures/msgpack/reference.pkl.msgpack new file mode 100644 index 00000000..ad0ae0af --- /dev/null +++ b/pkl/test_fixtures/msgpack/reference.pkl.msgpack @@ -0,0 +1 @@ +”©referenceÙ&pklgo:/pkl/test_fixtures/reference.pkl•“¤res0” ”«reference#DÙ&pklgo:/pkl/test_fixtures/reference.pkl¢hi“¤res1” ”«reference#DÙ&pklgo:/pkl/test_fixtures/reference.pkl¢hi‘”®pkl.ref#Access§pkl:ref”“ªisPropertyÓ«isSubscript“¨property£foo“£keyÀ“¤res2” ”«reference#DÙ&pklgo:/pkl/test_fixtures/reference.pkl¢hi’”®pkl.ref#Access§pkl:ref”“ªisPropertyÓ«isSubscript“¨property£bar“£keyÀ”®pkl.ref#Access§pkl:ref”“ªisProperty“«isSubscriptÓ¨propertyÀ“£key¢hi“¤res3” ”«reference#DÙ&pklgo:/pkl/test_fixtures/reference.pkl¢hi’”®pkl.ref#Access§pkl:ref”“ªisPropertyÓ«isSubscript“¨property£bar“£keyÀ”®pkl.ref#Access§pkl:ref”“ªisProperty“«isSubscriptÓ¨propertyÀ“£keyÀ“¤res4” ”«reference#DÙ&pklgo:/pkl/test_fixtures/reference.pkl¢hi’”®pkl.ref#Access§pkl:ref”“ªisPropertyÓ«isSubscript“¨property£baz“£keyÀ”®pkl.ref#Access§pkl:ref”“ªisProperty“«isSubscriptÓ¨propertyÀ“£key”°reference#MapKeyÙ&pklgo:/pkl/test_fixtures/reference.pkl‘“£key£foo \ No newline at end of file diff --git a/pkl/test_fixtures/reference.pkl b/pkl/test_fixtures/reference.pkl new file mode 100644 index 00000000..ee06c657 --- /dev/null +++ b/pkl/test_fixtures/reference.pkl @@ -0,0 +1,25 @@ +@go.Package { name = "github.com/apple/pkl-go/pkl/test_fixtures/gen/reference" } +module reference + +import "pkl:ref" + +import ".../codegen/src/go.pkl" + +class D extends ref.Domain +local d: D = new {} + +class A { + foo: Int + bar: Mapping + baz: Mapping +} + +class MapKey { + key: String +} + +res0: ref.Reference = ref.Reference(d, A, "hi") +res1: ref.Reference = res0.foo +res2: ref.Reference = res0.bar["hi"] +res3: ref.Reference = res0.bar[null] +res4: ref.Reference = res0.baz[new MapKey { key = "foo" }] diff --git a/pkl/unmarshal_test.go b/pkl/unmarshal_test.go index a02a1de8..ffbbce56 100644 --- a/pkl/unmarshal_test.go +++ b/pkl/unmarshal_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/apple/pkl-go/pkl" + "github.com/apple/pkl-go/pkl/test_fixtures/gen/reference" unknowntype "github.com/apple/pkl-go/pkl/test_fixtures/gen/unknown_type" any2 "github.com/apple/pkl-go/pkl/test_fixtures/gen/any" @@ -94,6 +95,9 @@ var typesInput []byte //go:embed test_fixtures/manual/types_pre_0.30.pkl.msgpack var typesPre030Input []byte +//go:embed test_fixtures/msgpack/reference.pkl.msgpack +var referenceInput []byte + func TestUnmarshall_Primitives(t *testing.T) { var res primitives.Primitives expected := primitives.Primitives{ @@ -488,3 +492,51 @@ func TestUnmarshal_Types_Pre_030(t *testing.T) { assert.Equal(t, types.Types{}, res) } + +func TestUnmarshal_Reference(t *testing.T) { + var res reference.Reference + assert.NoError(t, pkl.Unmarshal(referenceInput, &res)) + + foo := "foo" + bar := "bar" + baz := "baz" + + assert.Equal(t, reference.Reference{ + Res0: pkl.Reference[reference.D]{ + Domain: reference.DImpl{}, + Data: "hi", + Path: []pkl.ReferenceAccess{}, + }, + Res1: pkl.Reference[reference.D]{ + Domain: reference.DImpl{}, + Data: "hi", + Path: []pkl.ReferenceAccess{ + {Property: &foo}, + }, + }, + Res2: pkl.Reference[reference.D]{ + Domain: reference.DImpl{}, + Data: "hi", + Path: []pkl.ReferenceAccess{ + {Property: &bar}, + {Key: "hi"}, + }, + }, + Res3: pkl.Reference[reference.D]{ + Domain: reference.DImpl{}, + Data: "hi", + Path: []pkl.ReferenceAccess{ + {Property: &bar}, + {}, + }, + }, + Res4: pkl.Reference[reference.D]{ + Domain: reference.DImpl{}, + Data: "hi", + Path: []pkl.ReferenceAccess{ + {Property: &baz}, + {Key: reference.MapKey{Key: "foo"}}, + }, + }, + }, res) +} diff --git a/pkl/values.go b/pkl/values.go index 528faaae..d63250ee 100644 --- a/pkl/values.go +++ b/pkl/values.go @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. +// Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,10 @@ import ( "time" ) +func init() { + RegisterMappingFor[ReferenceAccess]("pkl.ref#Access") +} + // Object is the Go representation of `pkl.base#Object`. // It is a container for properties, entries, and elements. type Object struct { @@ -303,3 +307,32 @@ func ToDataSizeUnit(str string) (DataSizeUnit, error) { return Bytes, fmt.Errorf("unrecognized DataSize unit: `%s`", str) } } + +// Reference provides a representation for type-checked references to values that may not be set at runtime. +type Reference[D any] struct { + // Reference domain. + Domain D + + // Reference data. + Data any + + // Reference access path. + Path []ReferenceAccess +} + +// ReferenceAccess is an element of a [Reference]'s access path, representing property or subscript access. +type ReferenceAccess struct { + // If this is a property access, this will be a string containing the name of the accessed property. + // If this is a subscript access, this will be `nil`. + Property *string `pkl:"property"` + + // If this access is a subscript access, this will be the value of the key (which may be `nil`) and [Property] will be `nil`. + // If this is a property access, this will be `nil`. + Key any `pkl:"key"` +} + +// IsProperty indicates if this represents a property access. +func (a ReferenceAccess) IsProperty() bool { return a.Property != nil } + +// IsSubscript indicates if this represents a subscript access. +func (a ReferenceAccess) IsSubscript() bool { return a.Property == nil }