diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 17563b26..183f3893 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -85,6 +85,164 @@ class FieldAlias { ) } +class Ref { + public _fields = { + Name: $.varRef(''), + } + + static __typeInfo = $.registerStructType( + 'test.Ref', + new Ref(), + [], + Ref, + [ + { + name: 'Name', + key: 'Name', + type: { kind: $.TypeKind.Basic, name: 'string' }, + tag: 'json:"name"', + }, + ], + ) +} + +class Container { + public _fields = { + Contexts: $.varRef | null>(null), + } + + static __typeInfo = $.registerStructType( + 'test.Container', + new Container(), + [], + Container, + [ + { + name: 'Contexts', + key: 'Contexts', + type: { + kind: $.TypeKind.Map, + elemType: { kind: $.TypeKind.Slice, elemType: 'test.Ref' }, + }, + tag: 'json:"contexts"', + }, + ], + ) +} + +class Property { + public _fields = { + Type: $.varRef(''), + } + + static __typeInfo = $.registerStructType( + 'test.Property', + new Property(), + [], + Property, + [ + { + name: 'Type', + key: 'Type', + type: { kind: $.TypeKind.Basic, name: 'string' }, + tag: 'json:"type"', + }, + ], + ) +} + +class Schema { + public _fields = { + Properties: $.varRef | null>(null), + Items: $.varRef(null), + } + + static __typeInfo = $.registerStructType( + 'test.Schema', + new Schema(), + [], + Schema, + [ + { + name: 'Properties', + key: 'Properties', + type: { + kind: $.TypeKind.Map, + elemType: { kind: $.TypeKind.Pointer, elemType: 'test.Property' }, + }, + tag: 'json:"properties"', + }, + { + name: 'Items', + key: 'Items', + type: { kind: $.TypeKind.Pointer, elemType: 'test.Property' }, + tag: 'json:"items"', + }, + ], + ) +} + +class HookPointee { + public _fields = {} + public Text = '' + public Marker = '' + + UnmarshalJSON(data: $.Slice): $.GoError { + this.Text = $.bytesToString(data) + return null + } + + static __typeInfo = $.registerStructType( + 'test.HookPointee', + new HookPointee(), + [], + HookPointee, + [], + ) +} + +class HookHolder { + public _fields = { + Hook: $.varRef(null), + } + + static __typeInfo = $.registerStructType( + 'test.HookHolder', + new HookHolder(), + [], + HookHolder, + [ + { + name: 'Hook', + key: 'Hook', + type: { kind: $.TypeKind.Pointer, elemType: 'test.HookPointee' }, + tag: 'json:"hook"', + }, + ], + ) +} + +class Holder { + public _fields = { + Value: $.varRef(null), + } + + static __typeInfo = $.registerStructType( + 'test.Holder', + new Holder(), + [], + Holder, + [ + { + name: 'Value', + key: 'Value', + type: { kind: $.TypeKind.Interface, methods: [] }, + tag: 'json:"value"', + }, + ], + ) +} + describe('encoding/json override', () => { it('registers the Unmarshaler interface shape', () => { class CustomMarshaler implements Marshaler { @@ -361,6 +519,316 @@ describe('encoding/json override', () => { expect(mapRef.value?.get('active')).toBe(true) }) + it('decodes a map[string][]Struct field into real struct instances, not plain objects/arrays', () => { + const target = $.varRef(new Container()) + const err = Unmarshal( + $.stringToBytes('{"contexts":{"a":[{"name":"x"},{"name":"y"}]}}'), + target, + ) + + expect(err).toBeNull() + const contexts = target.value._fields.Contexts.value + expect(contexts).toBeInstanceOf(Map) + const refs = contexts?.get('a') + expect(refs).toHaveLength(2) + expect(refs?.[0]._fields.Name.value).toBe('x') + expect(refs?.[1]._fields.Name.value).toBe('y') + }) + + it('decodes array elements inside an interface{}-typed field into Maps, not plain objects', () => { + const target = $.varRef(new Holder()) + const err = Unmarshal( + $.stringToBytes('{"value":[{"x":1},{"y":2}]}'), + target, + ) + + expect(err).toBeNull() + const value = target.value._fields.Value.value as unknown[] + expect(Array.isArray(value)).toBe(true) + expect(value[0]).toBeInstanceOf(Map) + expect((value[0] as Map).get('x')).toBe(1) + expect((value[1] as Map).get('y')).toBe(2) + }) + + it('decodes pointer-to-struct map values and fields into real struct instances', () => { + const target = $.varRef(new Schema()) + const err = Unmarshal( + $.stringToBytes( + '{"properties":{"width":{"type":"number"}},"items":{"type":"string"}}', + ), + target, + ) + + expect(err).toBeNull() + const properties = target.value._fields.Properties.value + expect(properties?.get('width')?._fields.Type.value).toBe('number') + expect(target.value._fields.Items.value?._fields.Type.value).toBe( + 'string', + ) + }) + + it('allocates a nil pointer-to-struct field and invokes UnmarshalJSON on it, instead of decoding it field-by-field', () => { + const target = $.varRef(new HookHolder()) + const err = Unmarshal($.stringToBytes('{"hook":{"nested":true}}'), target) + + expect(err).toBeNull() + expect(target.value._fields.Hook.value).toBeInstanceOf(HookPointee) + expect(target.value._fields.Hook.value?.Text).toBe('{"nested":true}') + }) + + it('invokes UnmarshalJSON on a non-nil pointer-to-struct field in place, instead of replacing it with a freshly decoded instance', () => { + const holder = new HookHolder() + const existing = new HookPointee() + existing.Marker = 'orig' + holder._fields.Hook.value = existing + + const target = $.varRef(holder) + const err = Unmarshal($.stringToBytes('{"hook":{"nested":true}}'), target) + + expect(err).toBeNull() + // Go's encoding/json decodes into the existing pointee via its + // UnmarshalJSON hook, it never discards it for a fresh replacement. + expect(target.value._fields.Hook.value).toBe(existing) + expect(target.value._fields.Hook.value?.Marker).toBe('orig') + expect(target.value._fields.Hook.value?.Text).toBe('{"nested":true}') + }) + + it('decodes pointer-to-struct values as unmarked pointees, matching *Struct not Struct by value', () => { + const target = $.varRef(new Schema()) + const err = Unmarshal( + $.stringToBytes( + '{"properties":{"width":{"type":"number"}},"items":{"type":"string"}}', + ), + target, + ) + expect(err).toBeNull() + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + const propertyPointerType: $.TypeInfo = { + kind: $.TypeKind.Pointer, + elemType: propertyType, + } + + // A *Property field/map-value must match *Property (pointer), not + // Property (value): the runtime distinguishes pointer vs. value structs + // with a marker, and getting this wrong breaks Go type + // assertions/switches and pointer-receiver method-set checks on + // JSON-decoded values. + const items = target.value._fields.Items.value + expect($.is(items, propertyPointerType)).toBe(true) + expect($.is(items, propertyType)).toBe(false) + + const width = target.value._fields.Properties.value?.get('width') + expect($.is(width, propertyPointerType)).toBe(true) + expect($.is(width, propertyType)).toBe(false) + }) + + it('preserves pointer element types when parsing a top-level __goType spelling', () => { + // Regression: parsing a __goType spelling used to strip every leading + // '*' while recursing into nested element types, collapsing + // map[string]*test.Property's element type down to test.Property and + // losing pointer-vs-value semantics for the decoded elements. + const target = $.varRef | null>(new Map()) + target.__goType = '*map[string]*test.Property' + expect( + Unmarshal($.stringToBytes('{"width":{"type":"number"}}'), target), + ).toBeNull() + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + const propertyPointerType: $.TypeInfo = { + kind: $.TypeKind.Pointer, + elemType: propertyType, + } + const width = target.value?.get('width') + expect(width?._fields.Type.value).toBe('number') + expect($.is(width, propertyPointerType)).toBe(true) + expect($.is(width, propertyType)).toBe(false) + }) + + it('honors json tags on anonymous (inline, unnamed) struct targets', () => { + // The runtime boxes an Unmarshal target with no struct-field metadata of + // its own (an inline, unnamed struct type) with its type spelling via + // __goType, e.g. for: + // var loose struct { + // Name string `json:"name"` + // Age int `json:"age"` + // } + // json.Unmarshal(data, &loose) + const target = $.varRef<{ Name: string; Age: number }>({ + Name: '', + Age: 0, + }) + target.__goType = + '*struct{Name string "json:\\"name\\""; Age int "json:\\"age\\""}' + + const err = Unmarshal($.stringToBytes('{"name":"Ada","age":30}'), target) + expect(err).toBeNull() + expect(target.value.Name).toBe('Ada') + expect(target.value.Age).toBe(30) + }) + + it('honors json tags on an anonymous struct used as a map element type', () => { + // Same mechanism as the top-level case above, reached through + // decodeValueForType for a map[string]struct{...} spelling, e.g.: + // var loose map[string]struct { + // Name string `json:"name"` + // } + // json.Unmarshal(data, &loose) + const target = $.varRef | null>(null) + target.__goType = '*map[string]struct{Name string "json:\\"name\\""}' + + const err = Unmarshal( + $.stringToBytes('{"a":{"name":"Ada"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value?.get('a')).toEqual({ Name: 'Ada' }) + }) + + it('decodes a top-level pointer-to-struct var as an unmarked struct pointee', () => { + // `var p *test.Property; json.Unmarshal(data, &p)`: &p's own Go type is + // **test.Property (one star from p already being a pointer, one from + // &p); unmarshalTargetGoType strips only the outermost address-of star, + // leaving *test.Property, so this must go through the same Pointer + // handling as a pointer-typed field/element (see decodeValueForType's + // Pointer branch) rather than falling through to the untyped decode. + const target = $.varRef(null) + target.__goType = '**test.Property' + + const err = Unmarshal($.stringToBytes('{"type":"string"}'), target) + expect(err).toBeNull() + expect(target.value?._fields.Type.value).toBe('string') + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + const propertyPointerType: $.TypeInfo = { + kind: $.TypeKind.Pointer, + elemType: propertyType, + } + expect($.is(target.value, propertyPointerType)).toBe(true) + expect($.is(target.value, propertyType)).toBe(false) + }) + + it('honors disallowUnknownFields on anonymous struct targets', () => { + const target = $.varRef<{ Name: string }>({ Name: '' }) + target.__goType = '*struct{Name string "json:\\"name\\""}' + + const strictReader = bytes.NewBufferString('{"name":"Ada","extra":true}')! + const strictDecoder = NewDecoder(strictReader) + strictDecoder.DisallowUnknownFields() + + expect(strictDecoder.Decode(target)?.Error()).toBe( + 'json: unknown field "extra"', + ) + }) + + it('constructs a by-value named-struct field inside an anonymous struct target', () => { + // An anonymous struct's own fields start unset (the target object + // starts as {}), unlike a registered struct's fields, which are + // pre-initialized to a zero-value instance. A by-value named-struct + // field (Inner test.Property, no pointer) therefore has no pre-existing + // instance for the generic decode path to populate into and must be + // constructed directly. + const target = $.varRef<{ Inner: Property | undefined }>({ + Inner: undefined, + }) + target.__goType = '*struct{Inner test.Property "json:\\"inner\\""}' + + const err = Unmarshal( + $.stringToBytes('{"inner":{"type":"string"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value.Inner).toBeInstanceOf(Property) + expect(target.value.Inner?._fields.Type.value).toBe('string') + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + // A by-value struct field must match Property (value), not *Property. + expect($.is(target.value.Inner, propertyType)).toBe(true) + }) + + it('decodes a nested anonymous struct field, not a generic Map', () => { + // A field whose own type is itself an inline struct{...} (e.g. + // struct { + // Inner struct { + // Name string `json:"name"` + // } `json:"inner"` + // } + // ) has no registered TypeInfo of its own: resolveTypeInfo(field.type) + // returns null for it, same as any other anonymous struct descriptor. + // Without routing it through decodeValueForType, it fell through to the + // generic interface{} decode and came back as a Map keyed by the JSON + // tag ("name") instead of the anonymous struct representation keyed by + // the Go field name ("Name"). + const target = $.varRef<{ Inner: { Name: string } | undefined }>({ + Inner: undefined, + }) + target.__goType = + '*struct{Inner struct{Name string "json:\\"name\\""} "json:\\"inner\\""}' + + const err = Unmarshal( + $.stringToBytes('{"inner":{"name":"Ada"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value.Inner).toEqual({ Name: 'Ada' }) + }) + + it('decodes a pointer-to-anonymous-struct field, not a generic Map', () => { + // Same gap as above, one level of indirection further: a + // *struct{...} field. + const target = $.varRef<{ Inner: { Name: string } | null | undefined }>({ + Inner: undefined, + }) + target.__goType = + '*struct{Inner *struct{Name string "json:\\"name\\""} "json:\\"inner\\""}' + + const err = Unmarshal( + $.stringToBytes('{"inner":{"name":"Ada"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value.Inner).toEqual({ Name: 'Ada' }) + }) + + it('honors disallowUnknownFields on a nested anonymous struct field', () => { + const target = $.varRef<{ Inner: { Name: string } | undefined }>({ + Inner: undefined, + }) + target.__goType = + '*struct{Inner struct{Name string "json:\\"name\\""} "json:\\"inner\\""}' + + const strictReader = bytes.NewBufferString( + '{"inner":{"name":"Ada","extra":true}}', + )! + const strictDecoder = NewDecoder(strictReader) + strictDecoder.DisallowUnknownFields() + + expect(strictDecoder.Decode(target)?.Error()).toBe( + 'json: unknown field "extra"', + ) + }) + + it('decodes json.RawMessage as a map and slice element type', () => { + // The runtime boxes Unmarshal's `any` target with its Go type spelling + // (__goType) at the call site; simulate that here the way $.interfaceValue + // would for `var m map[string]json.RawMessage; json.Unmarshal(data, &m)`. + const mapTarget = $.varRef | null>(new Map()) + mapTarget.__goType = '*map[string]json.RawMessage' + expect( + Unmarshal($.stringToBytes('{"a":{"x":1},"b":[1,2,3]}'), mapTarget), + ).toBeNull() + expect($.bytesToString(mapTarget.value!.get('a')!)).toBe('{"x":1}') + expect($.bytesToString(mapTarget.value!.get('b')!)).toBe('[1,2,3]') + + const sliceTarget = $.varRef([]) + sliceTarget.__goType = '*[]json.RawMessage' + expect( + Unmarshal($.stringToBytes('[{"x":1},"raw"]'), sliceTarget), + ).toBeNull() + expect($.bytesToString(sliceTarget.value[0])).toBe('{"x":1}') + expect($.bytesToString(sliceTarget.value[1])).toBe('"raw"') + }) + it('encodes JSON to an io.Writer with newline, indentation, and HTML escaping', () => { const chunks: string[] = [] const writer = { diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index 65633694..725f8b57 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1013,8 +1013,51 @@ function assignDecodedValue( target.value = base64Decode(decoded) return } - if (isPlainObject(decoded)) { - target.value = objectToMap(decoded) + // A top-level map/slice var carries its Go type spelling (including its + // element type) via __goType, which the runtime boxes onto Unmarshal's + // `any` target (see unmarshalTargetGoType/goTypeDescriptor below). + // Decoding per that spelling, instead of the shape-driven decode below, + // is what makes e.g. `var m map[string]json.RawMessage` or + // `var m map[string]*Property` populate real typed elements instead of + // plain JS objects/strings. + const spelling = unmarshalTargetGoType(target) + if (spelling !== null) { + const desc = goTypeDescriptor(spelling) + // An anonymous (inline, unnamed) struct target, e.g. `var loose + // struct{ Name string "json:\"name\"" }`, carries no _fields/registered + // type metadata of its own to decode against, so it needs its json + // tags read from the parsed __goType spelling instead. See + // assignAnonymousStructFields below. + const anonFields = anonymousStructFields(desc) + if ( + anonFields !== null && + isPlainObject(decoded) && + isPlainObject(target.value) + ) { + assignAnonymousStructFields(target.value, decoded, anonFields, opts) + return + } + const info = resolveTypeInfo(desc) + if (info !== null) { + if ( + ($.isSliceTypeInfo(info) && Array.isArray(decoded)) || + ($.isMapTypeInfo(info) && isPlainObject(decoded)) || + // A top-level *Struct var (e.g. `var p *pkg.T`) needs the same + // type-driven Pointer handling as a pointer-typed struct field or + // map/slice element (see decodeValueForType's Pointer branch): + // without it, decoding falls through to decodeInterfaceValue + // below and produces a Map/plain object instead of a real + // (unmarked) struct instance, breaking *T type assertions and + // pointer-receiver method-set checks on the decoded result. + ($.isPointerTypeInfo(info) && isPlainObject(decoded)) + ) { + target.value = decodeValueForType(decoded, desc, opts) + return + } + } + } + if (isPlainObject(decoded) || Array.isArray(decoded)) { + target.value = decodeInterfaceValue(decoded) return } target.value = decoded @@ -1070,15 +1113,482 @@ function assignDecodedFieldValue( target.value = $.stringToBytes(JSON.stringify(decoded)) return } + // Decode Map/Slice-typed fields (including nested combinations, e.g. + // map[string][]Struct) through the type-driven decoder below. Gated on the + // decoded JSON shape actually matching (array for Slice, object for Map) so + // every other field shape (basic types, []byte-as-base64-string, etc.) + // falls through to the untouched generic path exactly as before. + const info = resolveTypeInfo(fieldType) + if (info !== null) { + if ($.isSliceTypeInfo(info) && Array.isArray(decoded)) { + target.value = decodeValueForType(decoded, info, opts) + return + } + if ($.isMapTypeInfo(info) && isPlainObject(decoded)) { + target.value = decodeValueForType(decoded, info, opts) + return + } + // Pointer-to-struct fields (e.g. Items *ItemsSpec) start as nil, so the + // generic path below has no zero-value instance to populate — construct + // the pointee via the type-driven decoder. Decode with the Pointer + // TypeInfo itself (not its elemType) so decodeValueForType's Pointer + // branch handles the pointer-vs-value marking correctly; see its + // comment. Pointer-to-basic fields keep the generic path (their + // representation is not a struct instance). + if ($.isPointerTypeInfo(info) && isPlainObject(decoded)) { + const pointee = resolveTypeInfo(info.elemType) + if (pointee !== null && $.isStructTypeInfo(pointee)) { + // A field that already holds a non-nil pointer whose pointee + // implements UnmarshalJSON must still go through that hook: Go's + // encoding/json always prefers a custom decoder over field-by-field + // population, even when the field is pre-populated rather than nil. + // The type-driven constructor below only sees a fresh instance it + // allocates itself, so it cannot see this pre-existing target. + if (unmarshalJSONTarget(target.value) !== null) { + assignDecodedValue(target, decoded, opts) + return + } + target.value = decodeValueForType(decoded, info, opts) + return + } + } + } assignDecodedValue(target, decoded, opts) } -function objectToMap(decoded: Record): Map { - const out = new Map() - for (const [key, value] of Object.entries(decoded)) { - out.set(key, isPlainObject(value) ? objectToMap(value) : value) +// resolveTypeInfo normalizes a type descriptor (an inline TypeInfo object, or +// a qualified type-name string like "pkg.MyStruct") into a TypeInfo, looking +// up named types via $.getTypeByName. Returns null when the descriptor is +// missing or names a type the runtime has no registration for (e.g. a basic +// type name like "uint8" — those are handled as plain values). +function resolveTypeInfo(t: unknown): $.TypeInfo | null { + if (typeof t === 'string') { + return ($.getTypeByName(t) as $.TypeInfo | undefined) ?? null } - return out + if (typeof t === 'object' && t !== null && 'kind' in t) { + return t as $.TypeInfo + } + return null +} + +// decodeValueForType recursively decodes a raw JSON-parsed JS value into a +// properly-typed Go value per `typeInfo`: +// - Struct: construct the registered ctor and populate its fields. +// - Slice: decode each element per the slice's elemType (struct elements +// become real struct instances instead of leaking plain objects). +// - Map: build a real Map (this override's Go-map representation) whose +// values are decoded per the map's elemType — this is what fixes +// map[string]T struct/slice-of-struct fields (e.g. a +// `Contexts map[string][]Ref` field): a struct- or slice-valued MAP +// field previously fell through to the shape-driven interface{} decode +// below and silently produced a Map of plain JS objects/arrays instead +// of real struct instances, so Go field access on the decoded values +// (e.g. `contexts.Foo`) read back empty/undefined. +// - json.RawMessage element: captures the raw JSON fragment as bytes, +// mirroring the direct-struct-field RawMessage path in +// assignDecodedFieldValue above. +// - Pointer element: decodes as its pointee (the runtime represents a +// *Struct value as the struct instance itself for field access) — +// without this, map/slice elements typed e.g. map[string]*Property +// leaked plain objects whose Go field reads came back undefined. +// - Anonymous struct element: decodes to a plain object keyed by Go field +// name, honoring the field's own json tags (see +// assignAnonymousStructFields below) — the runtime's representation of +// an inline struct{...} value. +// - Missing type info / interface{} elements: shape-driven decode via +// decodeInterfaceValue, mirroring Go's json.Unmarshal-into-any. +function decodeValueForType( + decoded: unknown, + typeInfo: unknown, + opts: decodeOptions, +): unknown { + if (isRawMessageType(typeInfo) && decoded !== undefined) { + return $.stringToBytes(JSON.stringify(decoded)) + } + if (decoded === null || decoded === undefined) { + return decoded + } + const anonFields = anonymousStructFields(typeInfo) + if (anonFields !== null) { + if (!isPlainObject(decoded)) { + return decoded + } + const out: Record = {} + assignAnonymousStructFields(out, decoded, anonFields, opts) + return out + } + const info = resolveTypeInfo(typeInfo) + if (info === null || $.isInterfaceTypeInfo(info)) { + return decodeInterfaceValue(decoded) + } + if ($.isPointerTypeInfo(info)) { + // A *Struct pointee must be constructed directly here, NOT by recursing + // into the Struct branch below: that branch always marks its result via + // $.markAsStructValue, but the runtime's pointer matching + // (matchesPointerType in gs/builtin/type.ts) requires struct pointees to + // stay UNMARKED — only a genuine non-pointer struct value is marked. + // Getting this wrong breaks *Struct type assertions/switches and + // pointer-receiver method-set checks on decoded values. + const pointee = resolveTypeInfo(info.elemType) + if (pointee !== null && $.isStructTypeInfo(pointee)) { + if (!isPlainObject(decoded) || typeof pointee.ctor !== 'function') { + return decoded + } + const inst = new pointee.ctor() + // A freshly-allocated pointee that implements UnmarshalJSON must be + // decoded through that hook rather than field-by-field, mirroring Go: + // encoding/json always prefers a custom decoder over the default + // struct decode, including for a pointer field/element it allocates + // itself. + const unmarshaler = unmarshalJSONTarget(inst) + if (unmarshaler !== null) { + const err = unmarshaler.UnmarshalJSON( + $.stringToBytes(JSON.stringify(decoded)), + ) + if (err !== null) { + throw err + } + return inst + } + if (isStructValue(inst)) { + assignStructFields(inst, decoded, opts) + } + return inst + } + // Non-struct pointee (e.g. *string): decode as the pointee's own + // representation, which carries no pointer-vs-value marking. + return decodeValueForType(decoded, info.elemType, opts) + } + if ($.isStructTypeInfo(info)) { + if (!isPlainObject(decoded) || typeof info.ctor !== 'function') { + return decoded + } + const inst = new info.ctor() + if (isStructValue(inst)) { + assignStructFields(inst, decoded, opts) + } + return $.markAsStructValue(inst) + } + if ($.isSliceTypeInfo(info)) { + if (!Array.isArray(decoded)) { + return decoded + } + return decoded.map((el) => decodeValueForType(el, info.elemType, opts)) + } + if ($.isMapTypeInfo(info)) { + if (!isPlainObject(decoded)) { + return decoded + } + const out = new Map() + for (const [key, value] of Object.entries(decoded)) { + out.set(key, decodeValueForType(value, info.elemType, opts)) + } + return out + } + // Basic / other kinds: passthrough (RawMessage and []byte-as-base64 are + // handled by the caller before reaching the generic decoder). + return decoded +} + +// decodeInterfaceValue recursively decodes a raw JSON-parsed JS value the way +// Go's json.Unmarshal decodes into interface{}: objects become Maps (this +// override's Go-map representation) and arrays are decoded element-wise, so +// nested objects INSIDE arrays also become Maps, not just direct object +// values — both recursively, all the way down. Primitives pass through +// unchanged. +// +// Supersedes objectToMap, which only recursed into direct object VALUES, not +// array elements: an interface{}-typed array of objects (e.g. a Go []any +// holding map[string]any elements) left its element objects as raw plain JS +// objects instead of Maps. Go-side code that then type-switches on +// `case map[string]any:` and calls Map-only methods like .entries() on those +// elements panics with "entries is not a function". +function decodeInterfaceValue(decoded: unknown): unknown { + if (Array.isArray(decoded)) { + return decoded.map((el) => decodeInterfaceValue(el)) + } + if (isPlainObject(decoded)) { + const out = new Map() + for (const [key, value] of Object.entries(decoded)) { + out.set(key, decodeInterfaceValue(value)) + } + return out + } + return decoded +} + +// unmarshalTargetGoType returns the Go type spelling the runtime's +// $.interfaceValue boxes onto an Unmarshal target as __goType (e.g. +// `*map[string]json.RawMessage`), without the leading pointer star. Null +// when the target carries none (e.g. it was never passed through an `any` +// parameter, so there is no spelling to decode against). +function unmarshalTargetGoType(target: unknown): string | null { + if (target === null || typeof target !== 'object') { + return null + } + const t = Reflect.get(target, '__goType') + if (typeof t !== 'string' || t === '') { + return null + } + return t.startsWith('*') ? t.slice(1) : t +} + +// goTypeDescriptor parses a Go type spelling (from __goType, see above) into +// a decode descriptor consumable by decodeValueForType / resolveTypeInfo: +// - {kind: Pointer, elemType} for *T (e.g. a map[string]*T element +// spelling) — NOT stripped, so +// decodeValueForType's Pointer branch can +// apply the correct pointer-vs-value marking +// (see its comment). Only the single +// outermost star representing the Unmarshal +// target's own address-of is stripped, by +// unmarshalTargetGoType above, before this +// function ever runs. +// - 'json.RawMessage' RawMessage marker (isRawMessageType) +// - {kind: Slice|Map, elemType} for []T / map[K]V +// - {anonymousStructFields} for inline struct{...} types +// - the spelling itself for named types ($.getTypeByName resolves) +// - null for interface{} and unparseable spellings +// (shape-driven decode, same as before) +function goTypeDescriptor(spelling: string): unknown { + const s = spelling.trim() + if (s.startsWith('*')) { + return { + kind: $.TypeKind.Pointer, + elemType: goTypeDescriptor(s.slice(1).trim()), + } + } + if (s === 'json.RawMessage' || s === 'encoding/json.RawMessage') { + return 'json.RawMessage' + } + if (s === 'any' || s === 'interface{}' || s === 'interface {}') { + return null + } + if (s.startsWith('[]')) { + return { kind: $.TypeKind.Slice, elemType: goTypeDescriptor(s.slice(2)) } + } + if (s.startsWith('map[')) { + const keyEnd = matchingBracketIndex(s, 'map['.length - 1) + if (keyEnd < 0) { + return null + } + return { + kind: $.TypeKind.Map, + elemType: goTypeDescriptor(s.slice(keyEnd + 1)), + } + } + if (s.startsWith('struct{') && s.endsWith('}')) { + return { + anonymousStructFields: parseAnonymousStructFields( + s.slice('struct{'.length, -1), + ), + } + } + return s +} + +// parseAnonymousStructFields parses the field list of an inline struct type +// spelling ("Name Type" or "Name Type \"tag\"" decls separated by ';') into +// fieldMetadata. Embedded fields (no explicit name) are skipped: there is no +// way to address their promoted keys from this spelling alone. +function parseAnonymousStructFields(body: string): fieldMetadata[] { + const fields: fieldMetadata[] = [] + for (const decl of splitGoFieldDecls(body)) { + const [spec, tag] = splitGoFieldTag(decl.trim()) + const space = spec.indexOf(' ') + if (space < 0) { + continue + } + const name = spec.slice(0, space) + fields.push({ + key: name, + name, + type: goTypeDescriptor(spec.slice(space + 1).trim()), + ...(tag !== undefined ? { tag } : {}), + }) + } + return fields +} + +// splitGoFieldDecls splits a struct-type field list on ';' at nesting depth +// zero, skipping string literals (tags) so a ';' inside a tag cannot split. +function splitGoFieldDecls(body: string): string[] { + const decls: string[] = [] + let depth = 0 + let inStr = false + let start = 0 + for (let i = 0; i < body.length; i++) { + const c = body[i] + if (inStr) { + if (c === '\\') { + i++ + } else if (c === '"') { + inStr = false + } + continue + } + if (c === '"') { + inStr = true + } else if (c === '{' || c === '[') { + depth++ + } else if (c === '}' || c === ']') { + depth-- + } else if (c === ';' && depth === 0) { + decls.push(body.slice(start, i)) + start = i + 1 + } + } + decls.push(body.slice(start)) + return decls +} + +// splitGoFieldTag splits one field decl into [name+type, unquoted tag?]. The +// tag is the trailing Go-quoted literal at depth zero (a nested inline struct +// type keeps its own tags inside its braces, at depth > 0). +function splitGoFieldTag(decl: string): [string, string | undefined] { + let depth = 0 + for (let i = 0; i < decl.length; i++) { + const c = decl[i] + if (c === '{' || c === '[') { + depth++ + } else if (c === '}' || c === ']') { + depth-- + } else if (c === '"' && depth === 0) { + return [decl.slice(0, i).trim(), unquoteGoString(decl.slice(i).trim())] + } + } + return [decl, undefined] +} + +// unquoteGoString unquotes a Go double-quoted string literal. strconv.Quote +// output is JSON-compatible for the escapes tags use (\" and \\); anything +// exotic falls back to a plain backslash strip. +function unquoteGoString(lit: string): string { + try { + return JSON.parse(lit) as string + } catch { + return lit.slice(1, -1).replace(/\\(.)/g, '$1') + } +} + +// anonymousStructFields returns the parsed field list when the descriptor +// came from an inline struct{...} spelling, else null. +function anonymousStructFields(desc: unknown): fieldMetadata[] | null { + if (desc === null || typeof desc !== 'object') { + return null + } + const fields = (desc as { anonymousStructFields?: unknown }) + .anonymousStructFields + return Array.isArray(fields) ? (fields as fieldMetadata[]) : null +} + +// assignAnonymousStructFields populates an anonymous-struct value (a plain +// JS object keyed by Go field name, the runtime's representation of an +// inline struct) from decoded JSON, honoring json tags, disallowUnknownFields, +// and field types via the same per-field decode named-struct fields get +// (assignDecodedFieldValue). +function assignAnonymousStructFields( + target: Record, + decoded: Record, + fields: fieldMetadata[], + opts: decodeOptions, +): void { + if (opts.disallowUnknownFields) { + const knownNames = new Set() + for (const field of fields) { + const jsonName = jsonFieldName(field.name, field.tag) + if (jsonName !== '') { + knownNames.add(jsonName) + } + } + for (const key of Object.keys(decoded)) { + if (!knownNames.has(key)) { + throw $.newError(`json: unknown field "${key}"`) + } + } + } + for (const field of fields) { + const jsonName = jsonFieldName(field.name, field.tag) + if ( + jsonName === '' || + !Object.prototype.hasOwnProperty.call(decoded, jsonName) + ) { + continue + } + // An anonymous struct's own fields start unset (the target object + // starts as {}), so a by-value named-struct field (e.g. + // `Inner SomeStruct`) has no pre-existing instance for + // assignDecodedFieldValue's generic path to populate into, unlike a + // registered struct's fields (pre-initialized to a zero-value instance + // at construction). Construct it directly via the type-driven decoder + // whenever the field's type resolves to a real, ctor-bearing struct + // type (a bare type-name spelling resolved through $.getTypeByName); + // an inline TypeInfo with no ctor keeps the existing generic path + // unchanged below. + const fieldValue = decoded[jsonName] + const fieldTypeInfo = resolveTypeInfo(field.type) + if ( + fieldTypeInfo !== null && + $.isStructTypeInfo(fieldTypeInfo) && + typeof fieldTypeInfo.ctor === 'function' && + isPlainObject(fieldValue) + ) { + target[field.name] = decodeValueForType(fieldValue, fieldTypeInfo, opts) + continue + } + // A field whose own type is itself an inline struct{...} or + // *struct{...} (e.g. `Inner struct{ Name string "json:\"name\"" }`) has + // no registered TypeInfo of its own — resolveTypeInfo(field.type) is + // null for it, same as it is for a missing type. Left on the generic + // path below, it decodes as a plain interface{} value (a Map keyed by + // JSON tag) instead of the anonymous struct representation (a plain + // object keyed by Go field name). Route it through decodeValueForType + // directly, which recurses into assignAnonymousStructFields (or, for + // *struct{...}, its Pointer branch, which falls through to the same + // anonymous decode for a non-struct-typeinfo pointee) and so honors the + // field's own parsed json tags and disallowUnknownFields. + if (isAnonymousStructType(field.type) && isPlainObject(fieldValue)) { + target[field.name] = decodeValueForType(fieldValue, field.type, opts) + continue + } + const ref = $.varRef(target[field.name]) + assignDecodedFieldValue(ref, fieldValue, opts, field.type) + target[field.name] = ref.value + } +} + +// isAnonymousStructType reports whether `t` (a field's parsed type +// descriptor, from parseAnonymousStructFields) denotes struct{...} or +// *struct{...} — the shapes decodeValueForType constructs via +// assignAnonymousStructFields rather than a registered struct's ctor, and +// so must not be left on the generic assignDecodedFieldValue path. +function isAnonymousStructType(t: unknown): boolean { + if (anonymousStructFields(t) !== null) { + return true + } + if (typeof t === 'object' && t !== null && 'kind' in t) { + const info = t as $.TypeInfo + if ($.isPointerTypeInfo(info)) { + return isAnonymousStructType(info.elemType) + } + } + return false +} + +// matchingBracketIndex returns the index of the ']' matching the '[' at +// `open`, or -1. Tracks nesting only — Go map key spellings carry no strings +// or further brackets in the shapes goTypeDescriptor parses (map keys are +// always basic types). +function matchingBracketIndex(s: string, open: number): number { + let depth = 0 + for (let i = open; i < s.length; i++) { + if (s[i] === '[') depth++ + else if (s[i] === ']') { + depth-- + if (depth === 0) return i + } + } + return -1 } function isStructValue(