diff --git a/tsc/internal/lsp/lsproto/_generate/generate.mts b/tsc/internal/lsp/lsproto/_generate/generate.mts index 0fb164a65cf62..4e97c69f1e00c 100755 --- a/tsc/internal/lsp/lsproto/_generate/generate.mts +++ b/tsc/internal/lsp/lsproto/_generate/generate.mts @@ -2196,6 +2196,44 @@ function generateCode() { return exhaustive; } + /** + * Generate streaming discriminator dispatch for unions with at most one + * unmapped fallback arm. Fields after the discriminator decode directly + * from the active decoder; fields before it are replayed by the helper. + */ + function generateStreamingDiscriminatorDispatch( + name: string, + disc: NonNullable>, + indent: string, + ) { + writeLine(`${indent}state, err := scanDiscriminatedStruct(dec, "${name}", ${JSON.stringify(disc.fieldName)})`); + writeLine(`${indent}if err != nil {`); + writeLine(`${indent}\treturn err`); + writeLine(`${indent}}`); + writeLine(`${indent}switch string(state.discriminatorValue) {`); + for (const [value, entry] of disc.mapping) { + writeLine(`${indent}case \`"${value}"\`:`); + writeLine(`${indent}\treturn unmarshalDiscriminatedArm(state, &o.${entry.fieldName})`); + } + writeLine(`${indent}default:`); + if (disc.unmapped.length === 1) { + writeLine(`${indent}\treturn unmarshalDiscriminatedFallbackArm(state, &o.${disc.unmapped[0].fieldName})`); + } + else { + writeLine(`${indent}\treturn state.invalidDiscriminator()`); + } + writeLine(`${indent}}`); + } + + function canStreamDiscriminator( + disc: NonNullable>, + ): boolean { + return disc.unmapped.length <= 1 && disc.unmapped.every(entry => + entry.originalType.kind === "reference" + && model.structures.some(structure => structure.name === entry.originalType.name) + ); + } + /** * Generate try-each fallback code for unmapped entries, chaining into * presence dispatch if possible before falling back to raw try-each. @@ -3381,17 +3419,24 @@ function generateCode() { } } else { - // Ambiguous: buffer and dispatch - writeLine(`\t\tdata, err := dec.ReadValue()`); - writeLine(`\t\tif err != nil {`); - writeLine(`\t\t\treturn err`); - writeLine(`\t\t}`); let exhaustive = false; const disc = findDiscriminatorField(entries); - if (disc) { - exhaustive = generateDiscriminatorDispatch(disc, "\t\t"); + if (disc && canStreamDiscriminator(disc)) { + generateStreamingDiscriminatorDispatch(name, disc, "\t\t"); + exhaustive = true; } else { + // Ambiguous non-discriminated objects need the complete + // value for presence checks or speculative decoding. + writeLine(`\t\tdata, err := dec.ReadValue()`); + writeLine(`\t\tif err != nil {`); + writeLine(`\t\t\treturn err`); + writeLine(`\t\t}`); + } + if (disc && !canStreamDiscriminator(disc)) { + exhaustive = generateDiscriminatorDispatch(disc, "\t\t"); + } + else if (!disc) { const pres = findPresenceDiscriminator(entries); if (pres) { exhaustive = generatePresenceDispatch(pres, "\t\t"); @@ -3417,25 +3462,30 @@ function generateCode() { writeLine(`\t}`); } else { - // Fallback: unknown kinds present (e.g. `any`), use ReadValue + try-each. - writeLine("\tdata, err := dec.ReadValue()"); - writeLine("\tif err != nil {"); - writeLine("\t\treturn err"); - writeLine("\t}"); - - if (unionContainedNull) { - writeLine(`\tif string(data) == "null" {`); - writeLine(`\t\treturn nil`); - writeLine(`\t}`); - writeLine(""); - } - + // Fallback for unknown kinds (e.g. `any`). Discriminated object + // unions can still stream; other unions use ReadValue + try-each. let exhaustive = false; const disc = findDiscriminatorField(fieldEntries); - if (disc) { - exhaustive = generateDiscriminatorDispatch(disc, "\t"); + if (disc && canStreamDiscriminator(disc)) { + generateStreamingDiscriminatorDispatch(name, disc, "\t"); + exhaustive = true; } else { + writeLine("\tdata, err := dec.ReadValue()"); + writeLine("\tif err != nil {"); + writeLine("\t\treturn err"); + writeLine("\t}"); + if (unionContainedNull) { + writeLine(`\tif string(data) == "null" {`); + writeLine(`\t\treturn nil`); + writeLine(`\t}`); + writeLine(""); + } + } + if (disc && !canStreamDiscriminator(disc)) { + exhaustive = generateDiscriminatorDispatch(disc, "\t"); + } + else if (!disc) { const pres = findPresenceDiscriminator(fieldEntries); if (pres) { exhaustive = generatePresenceDispatch(pres, "\t"); diff --git a/tsc/internal/lsp/lsproto/lsp.go b/tsc/internal/lsp/lsproto/lsp.go index ae6c15b589ba1..9197f1c1dea1e 100644 --- a/tsc/internal/lsp/lsproto/lsp.go +++ b/tsc/internal/lsp/lsproto/lsp.go @@ -125,36 +125,6 @@ func jsonKeyCheck(name []byte, key string) bool { return len(name) == len(key)+2 && name[0] == '"' && string(name[1:len(name)-1]) == key } -// jsonObjectRawField scans the top-level keys of a JSON object looking for the -// given field name, and returns its raw JSON value (e.g. `"full"` with quotes). -// Returns nil if the field is not found. -func jsonObjectRawField(data []byte, field string) json.Value { - dec := json.NewDecoder(bytes.NewBuffer(data)) - if dec.PeekKind() != '{' { - return nil - } - if _, err := dec.ReadToken(); err != nil { - return nil - } - for dec.PeekKind() != '}' { - name, err := dec.ReadValue() - if err != nil { - return nil - } - if jsonKeyCheck(name, field) { - val, err := dec.ReadValue() - if err != nil { - return nil - } - return val - } - if err := dec.SkipValue(); err != nil { - return nil - } - } - return nil -} - // jsonObjectHasKey scans the top-level keys of a JSON object looking for any of the // given keys. Returns the index of the first key found, or -1 if none match. // Bails early on first match without decoding any values. diff --git a/tsc/internal/lsp/lsproto/lsp_generated.go b/tsc/internal/lsp/lsproto/lsp_generated.go index 31e2c37a7f617..5d8ede04361a0 100644 --- a/tsc/internal/lsp/lsproto/lsp_generated.go +++ b/tsc/internal/lsp/lsproto/lsp_generated.go @@ -11891,23 +11891,19 @@ var _ json.UnmarshalerFrom = (*TextDocumentEditOrCreateFileOrRenameFileOrDeleteF func (o *TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile) UnmarshalJSONFrom(dec *json.Decoder) error { *o = TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{} - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile", "kind") if err != nil { return err } - switch string(jsonObjectRawField(data, "kind")) { + switch string(state.discriminatorValue) { case `"rename"`: - o.RenameFile = new(RenameFile) - return json.Unmarshal(data, o.RenameFile) + return unmarshalDiscriminatedArm(state, &o.RenameFile) case `"create"`: - o.CreateFile = new(CreateFile) - return json.Unmarshal(data, o.CreateFile) + return unmarshalDiscriminatedArm(state, &o.CreateFile) case `"delete"`: - o.DeleteFile = new(DeleteFile) - return json.Unmarshal(data, o.DeleteFile) + return unmarshalDiscriminatedArm(state, &o.DeleteFile) default: - o.TextDocumentEdit = new(TextDocumentEdit) - return json.Unmarshal(data, o.TextDocumentEdit) + return unmarshalDiscriminatedFallbackArm(state, &o.TextDocumentEdit) } } @@ -11983,19 +11979,18 @@ var _ json.UnmarshalerFrom = (*WorkspaceFullDocumentDiagnosticReportOrUnchangedD func (o *WorkspaceFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport) UnmarshalJSONFrom(dec *json.Decoder) error { *o = WorkspaceFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport{} - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "WorkspaceFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport", "kind") if err != nil { return err } - switch string(jsonObjectRawField(data, "kind")) { + switch string(state.discriminatorValue) { case `"full"`: - o.FullDocumentDiagnosticReport = new(WorkspaceFullDocumentDiagnosticReport) - return json.Unmarshal(data, o.FullDocumentDiagnosticReport) + return unmarshalDiscriminatedArm(state, &o.FullDocumentDiagnosticReport) case `"unchanged"`: - o.UnchangedDocumentDiagnosticReport = new(WorkspaceUnchangedDocumentDiagnosticReport) - return json.Unmarshal(data, o.UnchangedDocumentDiagnosticReport) + return unmarshalDiscriminatedArm(state, &o.UnchangedDocumentDiagnosticReport) + default: + return state.invalidDiscriminator() } - return errInvalidValue("WorkspaceFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport", data) } type StringOrStringValue struct { @@ -12368,22 +12363,20 @@ var _ json.UnmarshalerFrom = (*WorkDoneProgressBeginOrReportOrEnd)(nil) func (o *WorkDoneProgressBeginOrReportOrEnd) UnmarshalJSONFrom(dec *json.Decoder) error { *o = WorkDoneProgressBeginOrReportOrEnd{} - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "WorkDoneProgressBeginOrReportOrEnd", "kind") if err != nil { return err } - switch string(jsonObjectRawField(data, "kind")) { + switch string(state.discriminatorValue) { case `"begin"`: - o.Begin = new(WorkDoneProgressBegin) - return json.Unmarshal(data, o.Begin) + return unmarshalDiscriminatedArm(state, &o.Begin) case `"report"`: - o.Report = new(WorkDoneProgressReport) - return json.Unmarshal(data, o.Report) + return unmarshalDiscriminatedArm(state, &o.Report) case `"end"`: - o.End = new(WorkDoneProgressEnd) - return json.Unmarshal(data, o.End) + return unmarshalDiscriminatedArm(state, &o.End) + default: + return state.invalidDiscriminator() } - return errInvalidValue("WorkDoneProgressBeginOrReportOrEnd", data) } type TextEditOrAnnotatedTextEditOrSnippetTextEdit struct { @@ -12436,19 +12429,18 @@ var _ json.UnmarshalerFrom = (*FullDocumentDiagnosticReportOrUnchangedDocumentDi func (o *FullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport) UnmarshalJSONFrom(dec *json.Decoder) error { *o = FullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport{} - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "FullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport", "kind") if err != nil { return err } - switch string(jsonObjectRawField(data, "kind")) { + switch string(state.discriminatorValue) { case `"full"`: - o.FullDocumentDiagnosticReport = new(FullDocumentDiagnosticReport) - return json.Unmarshal(data, o.FullDocumentDiagnosticReport) + return unmarshalDiscriminatedArm(state, &o.FullDocumentDiagnosticReport) case `"unchanged"`: - o.UnchangedDocumentDiagnosticReport = new(UnchangedDocumentDiagnosticReport) - return json.Unmarshal(data, o.UnchangedDocumentDiagnosticReport) + return unmarshalDiscriminatedArm(state, &o.UnchangedDocumentDiagnosticReport) + default: + return state.invalidDiscriminator() } - return errInvalidValue("FullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport", data) } type TextDocumentSyncOptionsOrKind struct { @@ -13590,22 +13582,20 @@ var _ json.UnmarshalerFrom = (*VSImageElementOrClassifiedTextElementOrContainerE func (o *VSImageElementOrClassifiedTextElementOrContainerElement) UnmarshalJSONFrom(dec *json.Decoder) error { *o = VSImageElementOrClassifiedTextElementOrContainerElement{} - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "VSImageElementOrClassifiedTextElementOrContainerElement", "_vs_type") if err != nil { return err } - switch string(jsonObjectRawField(data, "_vs_type")) { + switch string(state.discriminatorValue) { case `"ContainerElement"`: - o.ContainerElement = new(VSContainerElement) - return json.Unmarshal(data, o.ContainerElement) + return unmarshalDiscriminatedArm(state, &o.ContainerElement) case `"ImageElement"`: - o.ImageElement = new(VSImageElement) - return json.Unmarshal(data, o.ImageElement) + return unmarshalDiscriminatedArm(state, &o.ImageElement) case `"ClassifiedTextElement"`: - o.ClassifiedTextElement = new(VSClassifiedTextElement) - return json.Unmarshal(data, o.ClassifiedTextElement) + return unmarshalDiscriminatedArm(state, &o.ClassifiedTextElement) + default: + return state.invalidDiscriminator() } - return errInvalidValue("VSImageElementOrClassifiedTextElementOrContainerElement", data) } type LocationOrLocationsOrDefinitionLinksOrNull struct { @@ -14085,19 +14075,18 @@ var _ json.UnmarshalerFrom = (*RelatedFullDocumentDiagnosticReportOrUnchangedDoc func (o *RelatedFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport) UnmarshalJSONFrom(dec *json.Decoder) error { *o = RelatedFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport{} - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "RelatedFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport", "kind") if err != nil { return err } - switch string(jsonObjectRawField(data, "kind")) { + switch string(state.discriminatorValue) { case `"full"`: - o.FullDocumentDiagnosticReport = new(RelatedFullDocumentDiagnosticReport) - return json.Unmarshal(data, o.FullDocumentDiagnosticReport) + return unmarshalDiscriminatedArm(state, &o.FullDocumentDiagnosticReport) case `"unchanged"`: - o.UnchangedDocumentDiagnosticReport = new(RelatedUnchangedDocumentDiagnosticReport) - return json.Unmarshal(data, o.UnchangedDocumentDiagnosticReport) + return unmarshalDiscriminatedArm(state, &o.UnchangedDocumentDiagnosticReport) + default: + return state.invalidDiscriminator() } - return errInvalidValue("RelatedFullDocumentDiagnosticReportOrUnchangedDocumentDiagnosticReport", data) } type InlineCompletionListOrItemsOrNull struct { @@ -14701,22 +14690,20 @@ func (o *RequestFailureTelemetryEventOrPerformanceStatsTelemetryEventOrProjectIn _, err := dec.ReadToken() return err case '{': - data, err := dec.ReadValue() + state, err := scanDiscriminatedStruct(dec, "RequestFailureTelemetryEventOrPerformanceStatsTelemetryEventOrProjectInfoTelemetryEventOrNull", "eventName") if err != nil { return err } - switch string(jsonObjectRawField(data, "eventName")) { + switch string(state.discriminatorValue) { case `"languageServer.projectInfo"`: - o.ProjectInfoTelemetryEvent = new(ProjectInfoTelemetryEvent) - return json.Unmarshal(data, o.ProjectInfoTelemetryEvent) + return unmarshalDiscriminatedArm(state, &o.ProjectInfoTelemetryEvent) case `"languageServer.errorResponse"`: - o.RequestFailureTelemetryEvent = new(RequestFailureTelemetryEvent) - return json.Unmarshal(data, o.RequestFailureTelemetryEvent) + return unmarshalDiscriminatedArm(state, &o.RequestFailureTelemetryEvent) case `"languageServer.performanceStats"`: - o.PerformanceStatsTelemetryEvent = new(PerformanceStatsTelemetryEvent) - return json.Unmarshal(data, o.PerformanceStatsTelemetryEvent) + return unmarshalDiscriminatedArm(state, &o.PerformanceStatsTelemetryEvent) + default: + return state.invalidDiscriminator() } - return errInvalidValue("RequestFailureTelemetryEventOrPerformanceStatsTelemetryEventOrProjectInfoTelemetryEventOrNull", data) default: return errInvalidKind("RequestFailureTelemetryEventOrPerformanceStatsTelemetryEventOrProjectInfoTelemetryEventOrNull", dec.PeekKind()) } diff --git a/tsc/internal/lsp/lsproto/lsp_json_benchmark_test.go b/tsc/internal/lsp/lsproto/lsp_json_benchmark_test.go new file mode 100644 index 0000000000000..1b77648ee8022 --- /dev/null +++ b/tsc/internal/lsp/lsproto/lsp_json_benchmark_test.go @@ -0,0 +1,87 @@ +package lsproto + +import ( + "bytes" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/json" +) + +type bufferedWorkDoneProgressUnion struct { + begin *WorkDoneProgressBegin + report *WorkDoneProgressReport + end *WorkDoneProgressEnd +} + +func (o *bufferedWorkDoneProgressUnion) UnmarshalJSONFrom(dec *json.Decoder) error { + data, err := dec.ReadValue() + if err != nil { + return err + } + switch string(benchmarkRawField(data, "kind")) { + case `"begin"`: + o.begin = new(WorkDoneProgressBegin) + return json.Unmarshal(data, o.begin) + case `"report"`: + o.report = new(WorkDoneProgressReport) + return json.Unmarshal(data, o.report) + case `"end"`: + o.end = new(WorkDoneProgressEnd) + return json.Unmarshal(data, o.end) + default: + return errInvalidValue("bufferedWorkDoneProgressUnion", data) + } +} + +func benchmarkRawField(data []byte, field string) json.Value { + dec := json.NewDecoder(bytes.NewReader(data)) + if _, err := dec.ReadToken(); err != nil { + return nil + } + for dec.PeekKind() != '}' { + name, err := dec.ReadValue() + if err != nil { + return nil + } + if jsonKeyCheck(name, field) { + value, err := dec.ReadValue() + if err != nil { + return nil + } + return value + } + if err := dec.SkipValue(); err != nil { + return nil + } + } + return nil +} + +func BenchmarkUnmarshalDiscriminatedUnion(b *testing.B) { + inputs := map[string][]byte{ + "discriminator-first": []byte(`{"kind":"begin","title":"Indexing","cancellable":true,"message":"Scanning files","percentage":25}`), + "discriminator-last": []byte(`{"title":"Indexing","cancellable":true,"message":"Scanning files","percentage":25,"kind":"begin"}`), + } + for order, input := range inputs { + b.Run(order, func(b *testing.B) { + b.Run("buffered", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + var value bufferedWorkDoneProgressUnion + if err := json.Unmarshal(input, &value); err != nil { + b.Fatal(err) + } + } + }) + b.Run("streaming", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + var value WorkDoneProgressBeginOrReportOrEnd + if err := json.Unmarshal(input, &value); err != nil { + b.Fatal(err) + } + } + }) + }) + } +} diff --git a/tsc/internal/lsp/lsproto/lsp_json_test.go b/tsc/internal/lsp/lsproto/lsp_json_test.go index 3f88840c0b43e..d188905342fbf 100644 --- a/tsc/internal/lsp/lsproto/lsp_json_test.go +++ b/tsc/internal/lsp/lsproto/lsp_json_test.go @@ -630,12 +630,37 @@ func TestUnmarshalDiscriminatorUnion(t *testing.T) { assert.Assert(t, v.End != nil) }) + t.Run("discriminator after variant fields", func(t *testing.T) { + t.Parallel() + var v WorkDoneProgressBeginOrReportOrEnd + err := json.Unmarshal([]byte(`{"title": "Indexing", "percentage": 25, "kind": "begin"}`), &v) + assert.NilError(t, err) + assert.Assert(t, v.Begin != nil) + assert.Equal(t, v.Begin.Title, "Indexing") + assert.Assert(t, v.Begin.Percentage != nil) + assert.Equal(t, *v.Begin.Percentage, uint32(25)) + }) + t.Run("invalid discriminator", func(t *testing.T) { t.Parallel() var v WorkDoneProgressBeginOrReportOrEnd err := json.Unmarshal([]byte(`{"kind": "invalid"}`), &v) assert.Assert(t, err != nil) }) + + t.Run("non-string discriminator", func(t *testing.T) { + t.Parallel() + var v WorkDoneProgressBeginOrReportOrEnd + err := json.Unmarshal([]byte(`{"kind": null}`), &v) + assert.Assert(t, err != nil) + }) + + t.Run("missing discriminator", func(t *testing.T) { + t.Parallel() + var v WorkDoneProgressBeginOrReportOrEnd + err := json.Unmarshal([]byte(`{"message": "missing kind"}`), &v) + assert.ErrorContains(t, err, `missing discriminator "kind"`) + }) } func TestUnmarshalPresenceDiscriminatorUnion(t *testing.T) { @@ -711,6 +736,21 @@ func TestUnmarshalDocumentEditUnion(t *testing.T) { assert.Assert(t, v.DeleteFile == nil) }) + t.Run("TextDocumentEdit with non-string kind", func(t *testing.T) { + t.Parallel() + var v TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile + err := json.Unmarshal([]byte(`{ + "kind": null, + "textDocument": {"uri": "file:///a.ts", "version": 1}, + "edits": [] + }`), &v) + assert.NilError(t, err) + assert.Assert(t, v.TextDocumentEdit != nil) + assert.Assert(t, v.CreateFile == nil) + assert.Assert(t, v.RenameFile == nil) + assert.Assert(t, v.DeleteFile == nil) + }) + t.Run("CreateFile with kind create", func(t *testing.T) { t.Parallel() var v TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile @@ -721,6 +761,15 @@ func TestUnmarshalDocumentEditUnion(t *testing.T) { assert.Equal(t, v.CreateFile.Uri, DocumentUri("file:///new.ts")) }) + t.Run("CreateFile with kind after fields", func(t *testing.T) { + t.Parallel() + var v TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile + err := json.Unmarshal([]byte(`{"uri": "file:///new.ts", "kind": "create"}`), &v) + assert.NilError(t, err) + assert.Assert(t, v.CreateFile != nil) + assert.Equal(t, v.CreateFile.Uri, DocumentUri("file:///new.ts")) + }) + t.Run("RenameFile with kind rename", func(t *testing.T) { t.Parallel() var v TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile diff --git a/tsc/internal/lsp/lsproto/structcodec.go b/tsc/internal/lsp/lsproto/structcodec.go index 269364a0f5a86..7ab70cd734bc6 100644 --- a/tsc/internal/lsp/lsproto/structcodec.go +++ b/tsc/internal/lsp/lsproto/structcodec.go @@ -1,6 +1,7 @@ package lsproto import ( + "fmt" "reflect" "strings" "sync" @@ -31,6 +32,12 @@ type structSpec struct { requiredMask uint64 } +type structDecoder struct { + rv reflect.Value + spec *structSpec + seen uint64 +} + var structSpecCache sync.Map // reflect.Type -> *structSpec func specFor(t reflect.Type) *structSpec { @@ -68,6 +75,41 @@ func specFor(t reflect.Type) *structSpec { return actual.(*structSpec) } +func newStructDecoder(v any) *structDecoder { + rv := reflect.ValueOf(v).Elem() + return &structDecoder{ + rv: rv, + spec: specFor(rv.Type()), + } +} + +func (d *structDecoder) field(name string, kind json.Kind, decode func(any) error) error { + fs, ok := d.spec.byName[name] + if !ok { + return decode(nil) + } + if fs.requiredID >= 0 { + d.seen |= 1 << fs.requiredID + } + if fs.rejectNull && kind == 'n' { + return errNull(name) + } + return decode(d.rv.Field(fs.index).Addr().Interface()) +} + +func (d *structDecoder) finish() error { + if missing := d.spec.requiredMask &^ d.seen; missing != 0 { + var missingProps []string + for id, n := range d.spec.requiredNames { + if missing&(1<