From 6cdcfb5b2d5eb506230932bb424a6bf69c88a7bc Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Tue, 23 Jun 2026 03:24:35 +0900 Subject: [PATCH 1/7] unimplemented: return nil from notification handlers to keep the connection alive A non-nil error from a notification handler has no response slot, so the jsonrpc2 dispatcher escalates it to conn.fail and tears the connection down. UnimplementedServer/UnimplementedClient returned errNotImplemented uniformly, so an embedder that did not override every notification lost the connection the instant a peer sent one (e.g. gopls front-loading window/logMessage right after initialize, before initialized/didOpen). Return nil from the 26 notification stubs (ignoring the notification, as the LSP spec prescribes) and keep errNotImplemented only on request stubs, where it correctly becomes a method-not-found response. Doc comments now spell out the request-vs-notification distinction so the stubs are not "consistency-fixed" back. --- integration_test.go | 73 +++++++++++++++++++++++++++++++++++++++ unimplemented.go | 84 ++++++++++++++++++++++++++------------------- 2 files changed, 122 insertions(+), 35 deletions(-) diff --git a/integration_test.go b/integration_test.go index df1ce5d..360a63f 100644 --- a/integration_test.go +++ b/integration_test.go @@ -158,3 +158,76 @@ func TestIntegrationRoundTrip(t *testing.T) { t.Errorf("didOpen URI = %q, want %q", gotURI, wantURI) } } + +// notifyingServer pushes a server->client window/logMessage notification from +// inside its Hover handler. It models the common case of a server that emits +// log notifications early in a session, which is what exposed the connection +// teardown bug fixed in this change. +type notifyingServer struct { + UnimplementedServer + + mu sync.Mutex + client Client +} + +var _ Server = (*notifyingServer)(nil) + +func (s *notifyingServer) Hover(ctx context.Context, _ *HoverParams) (*Hover, error) { + s.mu.Lock() + client := s.client + s.mu.Unlock() + + // A server->client notification: with the bug present, the client's + // un-overridden UnimplementedClient.LogMessage returned errNotImplemented, + // which the dispatcher escalated to conn.fail and tore the connection down. + if err := client.LogMessage(ctx, &LogMessageParams{Type: MessageTypeInfo, Message: "hello"}); err != nil { + return nil, err + } + + return &Hover{Contents: String("ok")}, nil +} + +// TestIntegrationUnimplementedClientSurvivesNotification is the regression guard +// for the bug where an un-overridden UnimplementedClient notification method +// returned errNotImplemented, causing the jsonrpc2 dispatcher to fail the whole +// connection. A bare UnimplementedClient (overriding nothing) must absorb a +// server-sent window/logMessage notification and the connection must remain +// usable for a subsequent request. +func TestIntegrationUnimplementedClientSurvivesNotification(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + a, b := net.Pipe() + + ns := ¬ifyingServer{} + + _, connA, clientDispatcher := NewServer(ctx, ns, jsonrpc2.NewStream(a)) + defer func() { _ = connA.Close() }() + + ns.mu.Lock() + ns.client = clientDispatcher + ns.mu.Unlock() + + // The client overrides nothing: every notification falls through to + // UnimplementedClient, the exact configuration that previously killed the + // connection on the first server notification. + _, connB, serverDispatcher := NewClient(ctx, UnimplementedClient{}, jsonrpc2.NewStream(b)) + defer func() { _ = connB.Close() }() + + // The handler pushes window/logMessage to the client mid-request. If the + // notification tore the connection down, this Hover call would fail (or the + // LogMessage inside the handler would error and surface here). + hover, err := serverDispatcher.Hover(ctx, &HoverParams{}) + if err != nil { + t.Fatalf("hover call after server notification: %v", err) + } + if contents, ok := hover.Contents.(String); !ok || contents != "ok" { + t.Fatalf("hover contents = %#v, want String(%q)", hover.Contents, "ok") + } + + // Prove the connection is still alive after the notification by issuing a + // second request that must also round-trip. + if _, err := serverDispatcher.Hover(ctx, &HoverParams{}); err != nil { + t.Fatalf("second hover call: connection did not survive the notification: %v", err) + } +} diff --git a/unimplemented.go b/unimplemented.go index a6c2875..f0809ba 100644 --- a/unimplemented.go +++ b/unimplemented.go @@ -9,15 +9,25 @@ import ( "go.lsp.dev/jsonrpc2" ) -// errNotImplemented is the sentinel returned by every [UnimplementedServer] and -// [UnimplementedClient] method. It carries the JSON-RPC "method not found" code -// so peers observe a well-formed, classified error for un-overridden methods. +// errNotImplemented is the sentinel returned by every un-overridden request +// method of [UnimplementedServer] and [UnimplementedClient]. It carries the +// JSON-RPC "method not found" code so peers observe a well-formed, classified +// error response for un-overridden requests. +// +// Notification methods do NOT return this sentinel: a notification has no +// response, so the jsonrpc2 dispatcher treats a non-nil error from a +// notification handler as a connection-level failure and tears the connection +// down. An un-overridden notification therefore returns nil (the notification +// is silently ignored), which is also what the LSP specification prescribes for +// notifications a peer does not handle. var errNotImplemented = jsonrpc2.NewError(jsonrpc2.ErrMethodNotFound.Code, "not implemented") // UnimplementedServer is an embeddable default implementation of the [Server] -// interface. Every method returns [errNotImplemented] together with the zero -// value of its result, so consumers can embed it and override only the methods -// they support. +// interface. Each un-overridden request method returns [errNotImplemented] +// together with the zero value of its result, and each un-overridden +// notification method returns nil (ignoring the notification), so consumers can +// embed it and override only the methods they support without an un-overridden +// notification tearing down the connection. type UnimplementedServer struct{} // compile-time assertion that UnimplementedServer satisfies Server. @@ -28,35 +38,37 @@ func (UnimplementedServer) Initialize(context.Context, *InitializeParams) (*Init } func (UnimplementedServer) Initialized(context.Context, *InitializedParams) error { - return errNotImplemented + return nil // initialized is a notification; see errNotImplemented. } func (UnimplementedServer) Shutdown(context.Context) error { return errNotImplemented } -func (UnimplementedServer) Exit(context.Context) error { return errNotImplemented } +func (UnimplementedServer) Exit(context.Context) error { + return nil // exit is a notification; see errNotImplemented. +} func (UnimplementedServer) SetTrace(context.Context, *SetTraceParams) error { - return errNotImplemented + return nil // $/setTrace is a notification; see errNotImplemented. } func (UnimplementedServer) Progress(context.Context, *ProgressParams) error { - return errNotImplemented + return nil // $/progress is a notification; see errNotImplemented. } func (UnimplementedServer) WorkDoneProgressCancel(context.Context, *WorkDoneProgressCancelParams) error { - return errNotImplemented + return nil // window/workDoneProgress/cancel is a notification; see errNotImplemented. } func (UnimplementedServer) DidOpen(context.Context, *DidOpenTextDocumentParams) error { - return errNotImplemented + return nil // textDocument/didOpen is a notification; see errNotImplemented. } func (UnimplementedServer) DidChange(context.Context, *DidChangeTextDocumentParams) error { - return errNotImplemented + return nil // textDocument/didChange is a notification; see errNotImplemented. } func (UnimplementedServer) WillSave(context.Context, *WillSaveTextDocumentParams) error { - return errNotImplemented + return nil // textDocument/willSave is a notification; see errNotImplemented. } func (UnimplementedServer) WillSaveWaitUntil(context.Context, *WillSaveTextDocumentParams) ([]TextEdit, error) { @@ -64,27 +76,27 @@ func (UnimplementedServer) WillSaveWaitUntil(context.Context, *WillSaveTextDocum } func (UnimplementedServer) DidSave(context.Context, *DidSaveTextDocumentParams) error { - return errNotImplemented + return nil // textDocument/didSave is a notification; see errNotImplemented. } func (UnimplementedServer) DidClose(context.Context, *DidCloseTextDocumentParams) error { - return errNotImplemented + return nil // textDocument/didClose is a notification; see errNotImplemented. } func (UnimplementedServer) DidOpenNotebookDocument(context.Context, *DidOpenNotebookDocumentParams) error { - return errNotImplemented + return nil // notebookDocument/didOpen is a notification; see errNotImplemented. } func (UnimplementedServer) DidChangeNotebookDocument(context.Context, *DidChangeNotebookDocumentParams) error { - return errNotImplemented + return nil // notebookDocument/didChange is a notification; see errNotImplemented. } func (UnimplementedServer) DidSaveNotebookDocument(context.Context, *DidSaveNotebookDocumentParams) error { - return errNotImplemented + return nil // notebookDocument/didSave is a notification; see errNotImplemented. } func (UnimplementedServer) DidCloseNotebookDocument(context.Context, *DidCloseNotebookDocumentParams) error { - return errNotImplemented + return nil // notebookDocument/didClose is a notification; see errNotImplemented. } func (UnimplementedServer) Declaration(context.Context, *DeclarationParams) (DeclarationResult, error) { @@ -272,11 +284,11 @@ func (UnimplementedServer) WorkspaceSymbolResolve(context.Context, *WorkspaceSym } func (UnimplementedServer) DidChangeConfiguration(context.Context, *DidChangeConfigurationParams) error { - return errNotImplemented + return nil // workspace/didChangeConfiguration is a notification; see errNotImplemented. } func (UnimplementedServer) DidChangeWorkspaceFolders(context.Context, *DidChangeWorkspaceFoldersParams) error { - return errNotImplemented + return nil // workspace/didChangeWorkspaceFolders is a notification; see errNotImplemented. } func (UnimplementedServer) WillCreateFiles(context.Context, *CreateFilesParams) (*WorkspaceEdit, error) { @@ -292,19 +304,19 @@ func (UnimplementedServer) WillDeleteFiles(context.Context, *DeleteFilesParams) } func (UnimplementedServer) DidCreateFiles(context.Context, *CreateFilesParams) error { - return errNotImplemented + return nil // workspace/didCreateFiles is a notification; see errNotImplemented. } func (UnimplementedServer) DidRenameFiles(context.Context, *RenameFilesParams) error { - return errNotImplemented + return nil // workspace/didRenameFiles is a notification; see errNotImplemented. } func (UnimplementedServer) DidDeleteFiles(context.Context, *DeleteFilesParams) error { - return errNotImplemented + return nil // workspace/didDeleteFiles is a notification; see errNotImplemented. } func (UnimplementedServer) DidChangeWatchedFiles(context.Context, *DidChangeWatchedFilesParams) error { - return errNotImplemented + return nil // workspace/didChangeWatchedFiles is a notification; see errNotImplemented. } func (UnimplementedServer) ExecuteCommand(context.Context, *ExecuteCommandParams) (LSPAny, error) { @@ -320,20 +332,22 @@ func (UnimplementedServer) Request(context.Context, string, any) (any, error) { } // UnimplementedClient is an embeddable default implementation of the [Client] -// interface. Every method returns [errNotImplemented] together with the zero -// value of its result, so consumers can embed it and override only the methods -// they support. +// interface. Each un-overridden request method returns [errNotImplemented] +// together with the zero value of its result, and each un-overridden +// notification method returns nil (ignoring the notification), so consumers can +// embed it and override only the methods they support without an un-overridden +// notification tearing down the connection. type UnimplementedClient struct{} // compile-time assertion that UnimplementedClient satisfies Client. var _ Client = UnimplementedClient{} func (UnimplementedClient) Progress(context.Context, *ProgressParams) error { - return errNotImplemented + return nil // $/progress is a notification; see errNotImplemented. } func (UnimplementedClient) LogTrace(context.Context, *LogTraceParams) error { - return errNotImplemented + return nil // $/logTrace is a notification; see errNotImplemented. } func (UnimplementedClient) RegisterCapability(context.Context, *RegistrationParams) error { @@ -345,7 +359,7 @@ func (UnimplementedClient) UnregisterCapability(context.Context, *Unregistration } func (UnimplementedClient) ShowMessage(context.Context, *ShowMessageParams) error { - return errNotImplemented + return nil // window/showMessage is a notification; see errNotImplemented. } func (UnimplementedClient) ShowMessageRequest(context.Context, *ShowMessageRequestParams) (*MessageActionItem, error) { @@ -353,7 +367,7 @@ func (UnimplementedClient) ShowMessageRequest(context.Context, *ShowMessageReque } func (UnimplementedClient) LogMessage(context.Context, *LogMessageParams) error { - return errNotImplemented + return nil // window/logMessage is a notification; see errNotImplemented. } func (UnimplementedClient) ShowDocument(context.Context, *ShowDocumentParams) (*ShowDocumentResult, error) { @@ -365,11 +379,11 @@ func (UnimplementedClient) WorkDoneProgressCreate(context.Context, *WorkDoneProg } func (UnimplementedClient) Telemetry(context.Context, LSPAny) error { - return errNotImplemented + return nil // telemetry/event is a notification; see errNotImplemented. } func (UnimplementedClient) PublishDiagnostics(context.Context, *PublishDiagnosticsParams) error { - return errNotImplemented + return nil // textDocument/publishDiagnostics is a notification; see errNotImplemented. } func (UnimplementedClient) Configuration(context.Context, *ConfigurationParams) ([]LSPAny, error) { From ec635d6646bfedc3ffe93340819dc02152a3bf53 Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Tue, 23 Jun 2026 09:16:23 +0900 Subject: [PATCH 2/7] genlsp: flatten _-prefixed bases and emit empty struct{} The LSP metaModel splits InitializeParams into a private _InitializeParams base plus a WorkspaceFoldersInitializeParams mixin. Embedding the private base leaked an underscore-named type into the public API and forced the parent to reach its own fields through an extra indirection on the hot initialize decode path. Flatten any extends/mixins reference whose target name begins with "_" directly into the referrer: inline the base's embeds and rendered fields (rendered against the public owner so doc hints and hot-field overrides match), and stop emitting underscore structs as standalone types. InitializeParams now carries WorkDoneProgressParams and the eight former base fields itself, and _InitializeParams is gone. Also collapse zero-field, zero-embed structs to the canonical struct{} form, since gofmt keeps the multi-line empty body as-is. Regenerated output is included; the net reduction comes from the parent no longer delegating codec methods through the dropped embed. --- append_encoders.gen.go | 98 +++------------------- decoders.gen.go | 179 ++++------------------------------------ encoders.gen.go | 89 ++------------------ internal/genlsp/emit.go | 51 +++++++++++- lifecycle.gen.go | 56 ++++++------- types_unions.gen.go | 3 +- 6 files changed, 110 insertions(+), 366 deletions(-) diff --git a/append_encoders.gen.go b/append_encoders.gen.go index d43dded..4a2ebb6 100644 --- a/append_encoders.gen.go +++ b/append_encoders.gen.go @@ -6823,6 +6823,17 @@ func (x *InitializeParams) appendLSP(dst []byte) ([]byte, error) { return nil, err } } + if !x.WorkspaceFolders.IsZero() { + dst = appendObjectName(dst, &first, `workspaceFolders`) + if x.WorkspaceFolders.IsNull() { + dst = append(dst, nullLiteral...) + } else { + nv, _ := x.WorkspaceFolders.Get() + if dst, err = appendSliceWorkspaceFolderJSON(dst, nv); err != nil { + return nil, err + } + } + } dst = appendObjectName(dst, &first, `processId`) if x.ProcessID == nil { dst = append(dst, nullLiteral...) @@ -6872,17 +6883,6 @@ func (x *InitializeParams) appendLSP(dst []byte) ([]byte, error) { dst = appendObjectName(dst, &first, `trace`) dst = appendJSONString(dst, string(x.Trace)) } - if !x.WorkspaceFolders.IsZero() { - dst = appendObjectName(dst, &first, `workspaceFolders`) - if x.WorkspaceFolders.IsNull() { - dst = append(dst, nullLiteral...) - } else { - nv, _ := x.WorkspaceFolders.Get() - if dst, err = appendSliceWorkspaceFolderJSON(dst, nv); err != nil { - return nil, err - } - } - } return append(dst, '}'), nil } @@ -14789,82 +14789,6 @@ func (x *WorkspaceUnchangedDocumentDiagnosticReport) appendLSPJSON(dst []byte) ( return x.appendLSP(dst) } -func (x *_InitializeParams) appendLSP(dst []byte) ([]byte, error) { - if x == nil { - return append(dst, nullLiteral...), nil - } - var err error - _ = err - dst = append(dst, '{') - first := true - _ = first - if x.WorkDoneToken != nil { - dst = appendObjectName(dst, &first, `workDoneToken`) - if dst, err = appendUnionProgressTokenJSON(dst, x.WorkDoneToken); err != nil { - return nil, err - } - } - dst = appendObjectName(dst, &first, `processId`) - if x.ProcessID == nil { - dst = append(dst, nullLiteral...) - } else { - dst = appendInt32JSON(dst, int32(*x.ProcessID)) - } - if !isZeroGeneratedClientInfo(x.ClientInfo) { - dst = appendObjectName(dst, &first, `clientInfo`) - if dst, err = x.ClientInfo.appendLSP(dst); err != nil { - return nil, err - } - } - if x.Locale != nil { - dst = appendObjectName(dst, &first, `locale`) - if x.Locale == nil { - dst = append(dst, nullLiteral...) - } else { - dst = appendJSONString(dst, string(*x.Locale)) - } - } - if !x.RootPath.IsZero() { - dst = appendObjectName(dst, &first, `rootPath`) - if x.RootPath.IsNull() { - dst = append(dst, nullLiteral...) - } else { - nv, _ := x.RootPath.Get() - dst = appendJSONString(dst, string(nv)) - } - } - dst = appendObjectName(dst, &first, `rootUri`) - if x.RootURI == nil { - dst = append(dst, nullLiteral...) - } else { - dst = appendJSONString(dst, string(*x.RootURI)) - } - dst = appendObjectName(dst, &first, `capabilities`) - if dst, err = x.Capabilities.appendLSP(dst); err != nil { - return nil, err - } - if len(x.InitializationOptions) > 0 { - dst = appendObjectName(dst, &first, `initializationOptions`) - if dst, err = appendRawJSONValue(dst, x.InitializationOptions); err != nil { - return nil, err - } - } - if x.Trace != "" { - dst = appendObjectName(dst, &first, `trace`) - dst = appendJSONString(dst, string(x.Trace)) - } - return append(dst, '}'), nil -} - -// appendLSPJSON implements appendMarshaler with a pre-sized buffer. -func (x *_InitializeParams) appendLSPJSON(dst []byte) ([]byte, error) { - if x == nil { - return append(dst, nullLiteral...), nil - } - dst = slices.Grow(dst, 2048) - return x.appendLSP(dst) -} - func appendSliceCodeActionKindDocumentationJSON(dst []byte, x []CodeActionKindDocumentation) ([]byte, error) { dst = slices.Grow(dst, 2+len(x)*96) dst = append(dst, '[') diff --git a/decoders.gen.go b/decoders.gen.go index 33284ef..49f2751 100644 --- a/decoders.gen.go +++ b/decoders.gen.go @@ -14879,6 +14879,22 @@ func (x *InitializeParams) unmarshalLSP(raw []byte, i int) (int, error) { return i, err } i = n + case keyEquals(key, `workspaceFolders`): + if n, ok := dvNull(raw, i); ok { + x.WorkspaceFolders = Nullable[[]WorkspaceFolder]{set: true, null: true} + i = n + } else { + val, n, err := dvValue(raw, i) + if err != nil { + return n, err + } + var v []WorkspaceFolder + if err := decodeWith(val, &v); err != nil { + return i, err + } + x.WorkspaceFolders = Nullable[[]WorkspaceFolder]{set: true, value: v} + i = n + } case keyEquals(key, `processId`): if n, ok := dvNull(raw, i); ok { x.ProcessID = nil @@ -14962,22 +14978,6 @@ func (x *InitializeParams) unmarshalLSP(raw []byte, i int) (int, error) { } x.Trace = TraceValue(v) i = n - case keyEquals(key, `workspaceFolders`): - if n, ok := dvNull(raw, i); ok { - x.WorkspaceFolders = Nullable[[]WorkspaceFolder]{set: true, null: true} - i = n - } else { - val, n, err := dvValue(raw, i) - if err != nil { - return n, err - } - var v []WorkspaceFolder - if err := decodeWith(val, &v); err != nil { - return i, err - } - x.WorkspaceFolders = Nullable[[]WorkspaceFolder]{set: true, value: v} - i = n - } default: _, n, err := dvValue(raw, i) if err != nil { @@ -32196,153 +32196,6 @@ func (x *WorkspaceUnchangedDocumentDiagnosticReport) UnmarshalJSONFrom(dec *json return x.unmarshalLSPValue(slices.Clone(raw)) } -func (x *_InitializeParams) unmarshalLSP(raw []byte, i int) (int, error) { - if n, ok := dvNull(raw, i); ok { - *x = _InitializeParams{} - return n, nil - } - if i >= len(raw) || raw[i] != '{' { - return i, dvSyntaxError(i, "object") - } - i = skipSpace(raw, i+1) - if i < len(raw) && raw[i] == '}' { - return i + 1, nil - } - for { - key, n, err := dvMemberKey(raw, i) - if err != nil { - return n, err - } - i = n - _ = key - switch { - case keyEquals(key, `workDoneToken`): - val, n, err := dvValue(raw, i) - if err != nil { - return n, err - } - if err := unmarshalProgressTokenValue(val, &x.WorkDoneToken); err != nil { - return i, err - } - i = n - case keyEquals(key, `processId`): - if n, ok := dvNull(raw, i); ok { - x.ProcessID = nil - i = n - } else { - v, n, err := dvInt32(raw, i) - if err != nil { - return n, err - } - if x.ProcessID == nil { - x.ProcessID = new(int32) - } - *x.ProcessID = v - i = n - } - case keyEquals(key, `clientInfo`): - n, err := x.ClientInfo.unmarshalLSP(raw, i) - if err != nil { - return n, err - } - i = n - case keyEquals(key, `locale`): - if n, ok := dvNull(raw, i); ok { - x.Locale = nil - i = n - } else { - v, n, err := dvString(raw, i) - if err != nil { - return n, err - } - if x.Locale == nil { - x.Locale = new(string) - } - *x.Locale = v - i = n - } - case keyEquals(key, `rootPath`): - if n, ok := dvNull(raw, i); ok { - x.RootPath = Nullable[string]{set: true, null: true} - i = n - } else { - v, n, err := dvString(raw, i) - if err != nil { - return n, err - } - x.RootPath = Nullable[string]{set: true, value: v} - i = n - } - case keyEquals(key, `rootUri`): - if n, ok := dvNull(raw, i); ok { - x.RootURI = nil - i = n - } else { - v, n, err := dvURI(raw, i) - if err != nil { - return n, err - } - if x.RootURI == nil { - x.RootURI = new(uri.URI) - } - *x.RootURI = v - i = n - } - case keyEquals(key, `capabilities`): - n, err := x.Capabilities.unmarshalLSP(raw, i) - if err != nil { - return n, err - } - i = n - case keyEquals(key, `initializationOptions`): - val, n, err := dvValue(raw, i) - if err != nil { - return n, err - } - x.InitializationOptions = jsontext.Value(val) - i = n - case keyEquals(key, `trace`): - v, n, err := dvString(raw, i) - if err != nil { - return n, err - } - x.Trace = TraceValue(v) - i = n - default: - _, n, err := dvValue(raw, i) - if err != nil { - return n, err - } - i = n - } - var done bool - i, done, err = dvObjectNext(raw, i) - if err != nil { - return i, err - } - if done { - return i, nil - } - } -} - -func (x *_InitializeParams) unmarshalLSPValue(raw jsontext.Value) error { - i, err := x.unmarshalLSP(raw, skipSpace(raw, 0)) - if err != nil { - return err - } - return dvEnd(raw, i) -} - -// UnmarshalJSONFrom implements the v2 UnmarshalerFrom interface via the byte walker. -func (x *_InitializeParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { - raw, err := dec.ReadValue() - if err != nil { - return err - } - return x.unmarshalLSPValue(slices.Clone(raw)) -} - func (x *Diagnostic) unmarshalLSPWithScalarBoxes(raw []byte, i int, capN int, scalarBoxes *[]String, markupBoxes *[]MarkupContent) (int, error) { if n, ok := dvNull(raw, i); ok { *x = Diagnostic{} diff --git a/encoders.gen.go b/encoders.gen.go index adc6f18..9f9ffae 100644 --- a/encoders.gen.go +++ b/encoders.gen.go @@ -4878,6 +4878,14 @@ func (x InitializeParams) MarshalJSONTo(enc *jsontext.Encoder) error { return err } } + if !x.WorkspaceFolders.IsZero() { + if err := enc.WriteToken(jsontext.String(`workspaceFolders`)); err != nil { + return err + } + if err := json.MarshalEncode(enc, x.WorkspaceFolders); err != nil { + return err + } + } if err := enc.WriteToken(jsontext.String(`processId`)); err != nil { return err } @@ -4936,14 +4944,6 @@ func (x InitializeParams) MarshalJSONTo(enc *jsontext.Encoder) error { return err } } - if !x.WorkspaceFolders.IsZero() { - if err := enc.WriteToken(jsontext.String(`workspaceFolders`)); err != nil { - return err - } - if err := json.MarshalEncode(enc, x.WorkspaceFolders); err != nil { - return err - } - } return enc.WriteToken(jsontext.EndObject) } @@ -10611,79 +10611,6 @@ func (x WorkspaceUnchangedDocumentDiagnosticReport) MarshalJSONTo(enc *jsontext. return enc.WriteToken(jsontext.EndObject) } -func (x _InitializeParams) MarshalJSONTo(enc *jsontext.Encoder) error { - if err := enc.WriteToken(jsontext.BeginObject); err != nil { - return err - } - if x.WorkDoneToken != nil { - if err := enc.WriteToken(jsontext.String(`workDoneToken`)); err != nil { - return err - } - if err := encodeProgressTokenTo(enc, x.WorkDoneToken); err != nil { - return err - } - } - if err := enc.WriteToken(jsontext.String(`processId`)); err != nil { - return err - } - if err := json.MarshalEncode(enc, x.ProcessID); err != nil { - return err - } - if !isZeroGeneratedClientInfo(x.ClientInfo) { - if err := enc.WriteToken(jsontext.String(`clientInfo`)); err != nil { - return err - } - if err := x.ClientInfo.MarshalJSONTo(enc); err != nil { - return err - } - } - if x.Locale != nil { - if err := enc.WriteToken(jsontext.String(`locale`)); err != nil { - return err - } - if err := json.MarshalEncode(enc, x.Locale); err != nil { - return err - } - } - if !x.RootPath.IsZero() { - if err := enc.WriteToken(jsontext.String(`rootPath`)); err != nil { - return err - } - if err := json.MarshalEncode(enc, x.RootPath); err != nil { - return err - } - } - if err := enc.WriteToken(jsontext.String(`rootUri`)); err != nil { - return err - } - if err := json.MarshalEncode(enc, x.RootURI); err != nil { - return err - } - if err := enc.WriteToken(jsontext.String(`capabilities`)); err != nil { - return err - } - if err := x.Capabilities.MarshalJSONTo(enc); err != nil { - return err - } - if len(x.InitializationOptions) > 0 { - if err := enc.WriteToken(jsontext.String(`initializationOptions`)); err != nil { - return err - } - if err := enc.WriteValue(x.InitializationOptions); err != nil { - return err - } - } - if x.Trace != "" { - if err := enc.WriteToken(jsontext.String(`trace`)); err != nil { - return err - } - if err := json.MarshalEncode(enc, x.Trace); err != nil { - return err - } - } - return enc.WriteToken(jsontext.EndObject) -} - func (x CompletionItemSlice) MarshalJSONTo(enc *jsontext.Encoder) error { if err := enc.WriteToken(jsontext.BeginArray); err != nil { return err diff --git a/internal/genlsp/emit.go b/internal/genlsp/emit.go index b31aff6..9a11a36 100644 --- a/internal/genlsp/emit.go +++ b/internal/genlsp/emit.go @@ -216,14 +216,30 @@ func detectImports(body string) []string { func (g *Generator) analyzeStructures() []*renderedStruct { out := make([]*renderedStruct, 0, len(g.model.Structures)) for _, s := range g.model.Structures { + if strings.HasPrefix(s.Name, "_") { + // Underscore-prefixed structures are private bases that exist only to + // be flattened into their public referrers; do not emit them as their + // own type. They remain in g.structures for lookup during flattening. + continue + } rs := &renderedStruct{ Name: s.Name, Doc: g.docComment(s.Name, s.Documentation, s.Since, s.Deprecated, s.Proposed), } for _, ref := range append(append([]*Type{}, s.Extends...), s.Mixins...) { - if ref.Kind == KindReference { - rs.Embeds = append(rs.Embeds, ref.Name) + if ref.Kind != KindReference { + continue } + if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { + // Merge the private base directly into this struct so the embed of + // the underscore type disappears while its contributed embeds and + // fields come along, rendered against the public owner. + embeds, fields := g.inlineContribution(s.Name, base) + rs.Embeds = append(rs.Embeds, embeds...) + rs.Fields = append(rs.Fields, fields...) + continue + } + rs.Embeds = append(rs.Embeds, ref.Name) } for _, p := range s.Properties { rs.Fields = append(rs.Fields, g.renderField(s.Name, p)) @@ -236,6 +252,29 @@ func (g *Generator) analyzeStructures() []*renderedStruct { return out } +// inlineContribution returns the embeds and fields a structure contributes when +// flattened into a referrer, recursively flattening any further +// underscore-prefixed references. owner is the public struct the fields are +// rendered against, so doc hints and hot-field overrides match the parent. +func (g *Generator) inlineContribution(owner string, s *Structure) (embeds []string, fields []renderedField) { + for _, ref := range append(append([]*Type{}, s.Extends...), s.Mixins...) { + if ref.Kind != KindReference { + continue + } + if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { + subEmbeds, subFields := g.inlineContribution(owner, base) + embeds = append(embeds, subEmbeds...) + fields = append(fields, subFields...) + continue + } + embeds = append(embeds, ref.Name) + } + for _, p := range s.Properties { + fields = append(fields, g.renderField(owner, p)) + } + return embeds, fields +} + func (g *Generator) renderField(owner string, p *Property) renderedField { fieldName := exportName(p.Name) hint := owner + fieldName @@ -298,6 +337,10 @@ func (g *Generator) renderStructures(structs []*renderedStruct) string { var b strings.Builder for _, s := range structs { b.WriteString(s.Doc) + if len(s.Embeds) == 0 && len(s.Fields) == 0 { + fmt.Fprintf(&b, "type %s struct{}\n\n", s.Name) + continue + } fmt.Fprintf(&b, "type %s struct {\n", s.Name) for _, e := range s.Embeds { fmt.Fprintf(&b, "\t%s\n", e) @@ -558,6 +601,10 @@ func (g *Generator) renderScalarWrappers(b *strings.Builder) { func (g *Generator) renderLiteralStruct(b *strings.Builder, d *literalDecl) { fmt.Fprintf(b, "// %s is a generated inline object literal type.\n", d.Name) + if len(d.Lit.Properties) == 0 { + fmt.Fprintf(b, "type %s struct{}\n\n", d.Name) + return + } fmt.Fprintf(b, "type %s struct {\n", d.Name) for i, p := range d.Lit.Properties { f := g.renderField(d.Name, p) diff --git a/lifecycle.gen.go b/lifecycle.gen.go index c402d15..8159bb2 100644 --- a/lifecycle.gen.go +++ b/lifecycle.gen.go @@ -11,38 +11,8 @@ import ( // InitializeParams is defined by the LSP specification. type InitializeParams struct { - _InitializeParams - WorkspaceFoldersInitializeParams -} - -// InitializeResult The result returned from an initialize request. -type InitializeResult struct { - // Capabilities The capabilities the language server provides. - Capabilities ServerCapabilities `json:"capabilities"` - - // ServerInfo Information about the server. - // - // Since: 3.15.0 - ServerInfo ServerInfo `json:"serverInfo,omitzero"` -} - -// InitializeError The data type of the ResponseError if the -// initialize request fails. -type InitializeError struct { - // Retry Indicates whether the client execute the following retry logic: - // (1) show the message provided by the ResponseError to the user - // (2) user selects retry or cancel - // (3) if user selected retry the initialize method is sent again. - Retry bool `json:"retry"` -} - -// InitializedParams is defined by the LSP specification. -type InitializedParams struct { -} - -// _InitializeParams The initialize parameters -type _InitializeParams struct { WorkDoneProgressParams + WorkspaceFoldersInitializeParams // ProcessID The process Id of the parent process that started // the server. @@ -89,6 +59,30 @@ type _InitializeParams struct { Trace TraceValue `json:"trace,omitzero"` } +// InitializeResult The result returned from an initialize request. +type InitializeResult struct { + // Capabilities The capabilities the language server provides. + Capabilities ServerCapabilities `json:"capabilities"` + + // ServerInfo Information about the server. + // + // Since: 3.15.0 + ServerInfo ServerInfo `json:"serverInfo,omitzero"` +} + +// InitializeError The data type of the ResponseError if the +// initialize request fails. +type InitializeError struct { + // Retry Indicates whether the client execute the following retry logic: + // (1) show the message provided by the ResponseError to the user + // (2) user selects retry or cancel + // (3) if user selected retry the initialize method is sent again. + Retry bool `json:"retry"` +} + +// InitializedParams is defined by the LSP specification. +type InitializedParams struct{} + // ServerCapabilities Defines the capabilities provided by a language // server. type ServerCapabilities struct { diff --git a/types_unions.gen.go b/types_unions.gen.go index f2563ee..1ad4d37 100644 --- a/types_unions.gen.go +++ b/types_unions.gen.go @@ -56,8 +56,7 @@ type MarkedStringSlice []MarkedString type ParameterInformationLabelTuple [2]uint32 // SemanticTokensOptionsRange is a generated inline object literal type. -type SemanticTokensOptionsRange struct { -} +type SemanticTokensOptionsRange struct{} // Definition The definition of a symbol represented as one or many [Location]. // For most programming languages there is only one location at which a symbol is From 8bb731f687001eaed05e8f6dc42daa6ebcaa13ca Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Tue, 23 Jun 2026 09:16:50 +0900 Subject: [PATCH 3/7] nullable: add NewNullable and NullNullable constructors Nullable[T] is tri-state (absent / explicit null / value), so the single NewOptional-style constructor only covers the value arm. Callers had to spell struct literals like Nullable[T]{set: true, null: true} to build the null state, mirroring internal generated code. Add NewNullable(v) for the present-value state and NullNullable[T]() for the explicit-null state. Absent needs no constructor since it is the zero Nullable[T]{}. Cover all three states and their JSON round-trip via a real Nullable field. --- nullable.go | 8 +++++ nullable_test.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/nullable.go b/nullable.go index edf43a7..c5d9223 100644 --- a/nullable.go +++ b/nullable.go @@ -25,6 +25,14 @@ type Nullable[T any] struct { null bool } +// NewNullable returns a Nullable holding the present value v (set=true, null=false). +func NewNullable[T any](v T) Nullable[T] { return Nullable[T]{set: true, value: v} } + +// NullNullable returns a Nullable that represents an explicit JSON null +// (set=true, null=true). This is the second non-zero tri-state; the third +// (absent) needs no constructor because it is the zero Nullable[T]{}. +func NullNullable[T any]() Nullable[T] { return Nullable[T]{set: true, null: true} } + // IsZero reports whether the value is absent. It drives the ",omitzero" tag so // an unset Nullable is omitted entirely. func (n Nullable[T]) IsZero() bool { return !n.set } diff --git a/nullable_test.go b/nullable_test.go index 696a9fc..ce674c8 100644 --- a/nullable_test.go +++ b/nullable_test.go @@ -3,7 +3,87 @@ package protocol -import "testing" +import ( + "testing" + + gocmp "github.com/google/go-cmp/cmp" +) + +// TestNewNullable verifies the three constructor paths for Nullable[T]: +// NewNullable (value state), NullNullable (null state), and the zero value +// (absent state). It also checks JSON round-trips through a real struct field. +func TestNewNullable(t *testing.T) { + t.Run("state predicates", func(t *testing.T) { + wf := WorkspaceFolder{URI: "file:///a", Name: "a"} + var zeroWF WorkspaceFolder + + tests := map[string]struct { + n Nullable[WorkspaceFolder] + wantIsZero bool + wantIsNull bool + wantVal WorkspaceFolder + wantOK bool + }{ + "success: NewNullable sets value state": {NewNullable(wf), false, false, wf, true}, + "success: NullNullable sets null state": {NullNullable[WorkspaceFolder](), false, true, zeroWF, false}, + "success: zero Nullable is absent state": {Nullable[WorkspaceFolder]{}, true, false, zeroWF, false}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := tt.n.IsZero(); got != tt.wantIsZero { + t.Errorf("IsZero = %v, want %v", got, tt.wantIsZero) + } + if got := tt.n.IsNull(); got != tt.wantIsNull { + t.Errorf("IsNull = %v, want %v", got, tt.wantIsNull) + } + got, ok := tt.n.Get() + if ok != tt.wantOK { + t.Errorf("Get() ok = %v, want %v", ok, tt.wantOK) + } + if diff := gocmp.Diff(tt.wantVal, got); diff != "" { + t.Errorf("Get() value mismatch (-want +got):\n%s", diff) + } + }) + } + }) + + t.Run("JSON round-trip via WorkspaceFoldersInitializeParams", func(t *testing.T) { + wf := []WorkspaceFolder{{URI: "file:///w", Name: "w"}} + + tests := map[string]struct { + build func() WorkspaceFoldersInitializeParams + wantJSON string + }{ + "success: NewNullable marshals to value": { + build: func() WorkspaceFoldersInitializeParams { + return WorkspaceFoldersInitializeParams{WorkspaceFolders: NewNullable(wf)} + }, + wantJSON: `{"workspaceFolders":[{"uri":"file:///w","name":"w"}]}`, + }, + "success: NullNullable marshals to null": { + build: func() WorkspaceFoldersInitializeParams { + return WorkspaceFoldersInitializeParams{WorkspaceFolders: NullNullable[[]WorkspaceFolder]()} + }, + wantJSON: `{"workspaceFolders":null}`, + }, + "success: zero Nullable is omitted": { + build: func() WorkspaceFoldersInitializeParams { return WorkspaceFoldersInitializeParams{} }, + wantJSON: `{}`, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + out, err := Marshal(tt.build()) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if got, want := canon(t, out), canon(t, []byte(tt.wantJSON)); got != want { + t.Errorf("JSON mismatch: got=%s want=%s", got, want) + } + }) + } + }) +} // TestNullableTriState verifies that an optional-AND-nullable field // distinguishes absent, explicit null, and a value across a JSON round-trip. From c735ab2aa516a12d04943196701bb6f92dd995f6 Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Wed, 24 Jun 2026 16:34:29 +0900 Subject: [PATCH 4/7] genlsp: deduplicate fields and embeds during structure flattening When recursively flattening private base structures (those prefixed with an underscore), diamond inheritance or overlapping mixins could previously produce duplicate struct fields or embedded types. This would lead to Go compile errors. This change introduces deduplication tracking in inlineContribution so each embedded type and property is emitted at most once per public structure. --- internal/genlsp/emit.go | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/internal/genlsp/emit.go b/internal/genlsp/emit.go index 9a11a36..6af70f7 100644 --- a/internal/genlsp/emit.go +++ b/internal/genlsp/emit.go @@ -257,21 +257,33 @@ func (g *Generator) analyzeStructures() []*renderedStruct { // underscore-prefixed references. owner is the public struct the fields are // rendered against, so doc hints and hot-field overrides match the parent. func (g *Generator) inlineContribution(owner string, s *Structure) (embeds []string, fields []renderedField) { - for _, ref := range append(append([]*Type{}, s.Extends...), s.Mixins...) { - if ref.Kind != KindReference { - continue + seenEmbeds := make(map[string]bool) + seenFields := make(map[string]bool) + + var flatten func(structure *Structure) + flatten = func(structure *Structure) { + for _, ref := range append(append([]*Type{}, structure.Extends...), structure.Mixins...) { + if ref.Kind != KindReference { + continue + } + if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { + flatten(base) + continue + } + if !seenEmbeds[ref.Name] { + seenEmbeds[ref.Name] = true + embeds = append(embeds, ref.Name) + } } - if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { - subEmbeds, subFields := g.inlineContribution(owner, base) - embeds = append(embeds, subEmbeds...) - fields = append(fields, subFields...) - continue + for _, p := range structure.Properties { + if !seenFields[p.Name] { + seenFields[p.Name] = true + fields = append(fields, g.renderField(owner, p)) + } } - embeds = append(embeds, ref.Name) - } - for _, p := range s.Properties { - fields = append(fields, g.renderField(owner, p)) } + + flatten(s) return embeds, fields } From 19a4c8baf3b97c090e109e7da74562ac46aa031f Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Sat, 27 Jun 2026 20:35:26 +0900 Subject: [PATCH 5/7] cleanup: keep protocol PR review focused Preserve the behavior-only cleanup as a narrow review aid. Keep generator traversal allocation-free with unchanged output. Document the stable NewClient return shape instead of breaking API. --- integration_test.go | 5 ---- internal/genlsp/emit.go | 54 ++++++++++++++++++++++------------------- nullable_test.go | 45 +++++++++++++++++++++++++++++----- protocol.go | 2 ++ 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/integration_test.go b/integration_test.go index 360a63f..1e54507 100644 --- a/integration_test.go +++ b/integration_test.go @@ -84,11 +84,6 @@ func (testClient) ApplyEdit(context.Context, *ApplyWorkspaceEditParams) (*ApplyW // 2. a client->server notification reaches the server (textDocument/didOpen); // 3. a server->client request issued from within a handler round-trips // (workspace/applyEdit). -// -// TestIntegrationRoundTrip drives the production Handlers() chain through -// NewServer/NewClient over an in-memory pipe: a client->server request, a -// notification, and a server->client callback. It is the regression guard for -// the reply-clobbering defect once present in protocol.CancelHandler. func TestIntegrationRoundTrip(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() diff --git a/internal/genlsp/emit.go b/internal/genlsp/emit.go index 6af70f7..eb24d65 100644 --- a/internal/genlsp/emit.go +++ b/internal/genlsp/emit.go @@ -226,20 +226,22 @@ func (g *Generator) analyzeStructures() []*renderedStruct { Name: s.Name, Doc: g.docComment(s.Name, s.Documentation, s.Since, s.Deprecated, s.Proposed), } - for _, ref := range append(append([]*Type{}, s.Extends...), s.Mixins...) { - if ref.Kind != KindReference { - continue - } - if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { - // Merge the private base directly into this struct so the embed of - // the underscore type disappears while its contributed embeds and - // fields come along, rendered against the public owner. - embeds, fields := g.inlineContribution(s.Name, base) - rs.Embeds = append(rs.Embeds, embeds...) - rs.Fields = append(rs.Fields, fields...) - continue + for _, refs := range [...][]*Type{s.Extends, s.Mixins} { + for _, ref := range refs { + if ref.Kind != KindReference { + continue + } + if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { + // Merge the private base directly into this struct so the embed of + // the underscore type disappears while its contributed embeds and + // fields come along, rendered against the public owner. + embeds, fields := g.inlineContribution(s.Name, base) + rs.Embeds = append(rs.Embeds, embeds...) + rs.Fields = append(rs.Fields, fields...) + continue + } + rs.Embeds = append(rs.Embeds, ref.Name) } - rs.Embeds = append(rs.Embeds, ref.Name) } for _, p := range s.Properties { rs.Fields = append(rs.Fields, g.renderField(s.Name, p)) @@ -262,17 +264,19 @@ func (g *Generator) inlineContribution(owner string, s *Structure) (embeds []str var flatten func(structure *Structure) flatten = func(structure *Structure) { - for _, ref := range append(append([]*Type{}, structure.Extends...), structure.Mixins...) { - if ref.Kind != KindReference { - continue - } - if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { - flatten(base) - continue - } - if !seenEmbeds[ref.Name] { - seenEmbeds[ref.Name] = true - embeds = append(embeds, ref.Name) + for _, refs := range [...][]*Type{structure.Extends, structure.Mixins} { + for _, ref := range refs { + if ref.Kind != KindReference { + continue + } + if base, ok := g.structures[ref.Name]; ok && strings.HasPrefix(ref.Name, "_") { + flatten(base) + continue + } + if !seenEmbeds[ref.Name] { + seenEmbeds[ref.Name] = true + embeds = append(embeds, ref.Name) + } } } for _, p := range structure.Properties { @@ -282,7 +286,7 @@ func (g *Generator) inlineContribution(owner string, s *Structure) (embeds []str } } } - + flatten(s) return embeds, fields } diff --git a/nullable_test.go b/nullable_test.go index ce674c8..d490a72 100644 --- a/nullable_test.go +++ b/nullable_test.go @@ -24,9 +24,27 @@ func TestNewNullable(t *testing.T) { wantVal WorkspaceFolder wantOK bool }{ - "success: NewNullable sets value state": {NewNullable(wf), false, false, wf, true}, - "success: NullNullable sets null state": {NullNullable[WorkspaceFolder](), false, true, zeroWF, false}, - "success: zero Nullable is absent state": {Nullable[WorkspaceFolder]{}, true, false, zeroWF, false}, + "success: NewNullable sets value state": { + n: NewNullable(wf), + wantIsZero: false, + wantIsNull: false, + wantVal: wf, + wantOK: true, + }, + "success: NullNullable sets null state": { + n: NullNullable[WorkspaceFolder](), + wantIsZero: false, + wantIsNull: true, + wantVal: zeroWF, + wantOK: false, + }, + "success: zero Nullable is absent state": { + n: Nullable[WorkspaceFolder]{}, + wantIsZero: true, + wantIsNull: false, + wantVal: zeroWF, + wantOK: false, + }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { @@ -94,9 +112,24 @@ func TestNullableTriState(t *testing.T) { wantNull bool wantLen int }{ - "success: absent": {`{}`, true, false, 0}, - "success: null": {`{"workspaceFolders":null}`, false, true, 0}, - "success: value": {`{"workspaceFolders":[{"uri":"file:///w","name":"w"}]}`, false, false, 1}, + "success: absent": { + json: `{}`, + wantAbsent: true, + wantNull: false, + wantLen: 0, + }, + "success: null": { + json: `{"workspaceFolders":null}`, + wantAbsent: false, + wantNull: true, + wantLen: 0, + }, + "success: value": { + json: `{"workspaceFolders":[{"uri":"file:///w","name":"w"}]}`, + wantAbsent: false, + wantNull: false, + wantLen: 1, + }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { diff --git a/protocol.go b/protocol.go index e3429a7..f5139a2 100644 --- a/protocol.go +++ b/protocol.go @@ -27,6 +27,8 @@ func NewServer(ctx context.Context, server Server, stream jsonrpc2.Stream) (cont // NewClient returns the context in which the [Client] is embedded, the jsonrpc2 // connection, and the [Server] dispatcher. The connection serves the supplied // [Client] and is wired with the union-aware [lspCodec]. +// +//nolint:unparam // returned context mirrors NewServer and is part of the stable symmetric API; callers may embed and reuse it func NewClient(ctx context.Context, client Client, stream jsonrpc2.Stream) (context.Context, jsonrpc2.Conn, Server) { ctx = WithClient(ctx, client) From dadabcc32128a0942215b5adcd859dcd35306beb Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Sat, 27 Jun 2026 21:04:09 +0900 Subject: [PATCH 6/7] genlsp: prevent private-base flattening collisions Share embed and field dedupe state across each public structure. Let public fields override inherited private-base fields before render. Cover duplicate private bases and direct public overrides in tests. --- internal/genlsp/emit.go | 21 ++++++++++----- internal/genlsp/emit_test.go | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/internal/genlsp/emit.go b/internal/genlsp/emit.go index eb24d65..b8b521f 100644 --- a/internal/genlsp/emit.go +++ b/internal/genlsp/emit.go @@ -226,6 +226,11 @@ func (g *Generator) analyzeStructures() []*renderedStruct { Name: s.Name, Doc: g.docComment(s.Name, s.Documentation, s.Since, s.Deprecated, s.Proposed), } + seenEmbeds := make(map[string]bool) + seenFields := make(map[string]bool) + for _, p := range s.Properties { + seenFields[p.Name] = true + } for _, refs := range [...][]*Type{s.Extends, s.Mixins} { for _, ref := range refs { if ref.Kind != KindReference { @@ -235,12 +240,15 @@ func (g *Generator) analyzeStructures() []*renderedStruct { // Merge the private base directly into this struct so the embed of // the underscore type disappears while its contributed embeds and // fields come along, rendered against the public owner. - embeds, fields := g.inlineContribution(s.Name, base) + embeds, fields := g.inlineContribution(s.Name, base, seenEmbeds, seenFields) rs.Embeds = append(rs.Embeds, embeds...) rs.Fields = append(rs.Fields, fields...) continue } - rs.Embeds = append(rs.Embeds, ref.Name) + if !seenEmbeds[ref.Name] { + seenEmbeds[ref.Name] = true + rs.Embeds = append(rs.Embeds, ref.Name) + } } } for _, p := range s.Properties { @@ -258,10 +266,11 @@ func (g *Generator) analyzeStructures() []*renderedStruct { // flattened into a referrer, recursively flattening any further // underscore-prefixed references. owner is the public struct the fields are // rendered against, so doc hints and hot-field overrides match the parent. -func (g *Generator) inlineContribution(owner string, s *Structure) (embeds []string, fields []renderedField) { - seenEmbeds := make(map[string]bool) - seenFields := make(map[string]bool) - +func (g *Generator) inlineContribution( + owner string, + s *Structure, + seenEmbeds, seenFields map[string]bool, +) (embeds []string, fields []renderedField) { var flatten func(structure *Structure) flatten = func(structure *Structure) { for _, refs := range [...][]*Type{structure.Extends, structure.Mixins} { diff --git a/internal/genlsp/emit_test.go b/internal/genlsp/emit_test.go index 2426c25..6e86430 100644 --- a/internal/genlsp/emit_test.go +++ b/internal/genlsp/emit_test.go @@ -102,6 +102,57 @@ func TestGeneratedFileName(t *testing.T) { } } +func TestAnalyzeStructuresDeduplicatesFlattenedPrivateBases(t *testing.T) { + ref := func(name string) *Type { return &Type{Kind: KindReference, Name: name} } + prop := func(name string) *Property { + return &Property{Name: name, Type: &Type{Kind: KindBase, Name: string(BaseString)}} + } + m := &MetaModel{ + Structures: []*Structure{ + {Name: "SharedEmbed"}, + { + Name: "_BaseA", + Extends: []*Type{ref("SharedEmbed")}, + Properties: []*Property{prop("shadow"), prop("baseAOnly")}, + }, + { + Name: "_BaseB", + Extends: []*Type{ref("SharedEmbed")}, + Properties: []*Property{prop("shadow"), prop("baseBOnly")}, + }, + { + Name: "Public", + Extends: []*Type{ref("_BaseA"), ref("_BaseB"), ref("SharedEmbed")}, + Properties: []*Property{prop("shadow"), prop("own")}, + }, + }, + } + g := NewGenerator(m, "protocol") + + var public *renderedStruct + for _, s := range g.analyzeStructures() { + if s.Name == "Public" { + public = s + break + } + } + if public == nil { + t.Fatal("Public structure not rendered") + } + if want := []string{"SharedEmbed"}; !slices.Equal(public.Embeds, want) { + t.Fatalf("Public embeds = %v, want %v", public.Embeds, want) + } + + var gotFields []string + for _, f := range public.Fields { + gotFields = append(gotFields, f.Name) + } + wantFields := []string{"BaseAOnly", "BaseBOnly", "Shadow", "Own"} + if !slices.Equal(gotFields, wantFields) { + t.Fatalf("Public fields = %v, want %v", gotFields, wantFields) + } +} + func TestHotOptionalField(t *testing.T) { tests := map[string]struct { owner string From dbfd5347c9ea87c20cac4c9bf1f1346aff59a71d Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Sat, 27 Jun 2026 21:39:19 +0900 Subject: [PATCH 7/7] genlsp: guard private-base flattening cycles Stop recursive private-base flattening when a structure repeats. Cover cyclic private bases so generation cannot recurse forever. Keep generated output unchanged for the current acyclic meta-model. --- internal/genlsp/emit.go | 6 ++++++ internal/genlsp/emit_test.go | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/genlsp/emit.go b/internal/genlsp/emit.go index b8b521f..9060011 100644 --- a/internal/genlsp/emit.go +++ b/internal/genlsp/emit.go @@ -271,8 +271,14 @@ func (g *Generator) inlineContribution( s *Structure, seenEmbeds, seenFields map[string]bool, ) (embeds []string, fields []renderedField) { + visited := make(map[string]bool) var flatten func(structure *Structure) flatten = func(structure *Structure) { + if visited[structure.Name] { + return + } + visited[structure.Name] = true + for _, refs := range [...][]*Type{structure.Extends, structure.Mixins} { for _, ref := range refs { if ref.Kind != KindReference { diff --git a/internal/genlsp/emit_test.go b/internal/genlsp/emit_test.go index 6e86430..03b66a0 100644 --- a/internal/genlsp/emit_test.go +++ b/internal/genlsp/emit_test.go @@ -125,14 +125,34 @@ func TestAnalyzeStructuresDeduplicatesFlattenedPrivateBases(t *testing.T) { Extends: []*Type{ref("_BaseA"), ref("_BaseB"), ref("SharedEmbed")}, Properties: []*Property{prop("shadow"), prop("own")}, }, + { + Name: "_CycleA", + Extends: []*Type{ref("_CycleB")}, + Properties: []*Property{prop("cycleA")}, + }, + { + Name: "_CycleB", + Extends: []*Type{ref("_CycleA")}, + Properties: []*Property{prop("cycleB")}, + }, + { + Name: "PublicCycle", + Extends: []*Type{ref("_CycleA")}, + Properties: []*Property{prop("cycleOwn")}, + }, }, } g := NewGenerator(m, "protocol") - var public *renderedStruct + var public, publicCycle *renderedStruct for _, s := range g.analyzeStructures() { - if s.Name == "Public" { + switch s.Name { + case "Public": public = s + case "PublicCycle": + publicCycle = s + } + if public != nil && publicCycle != nil { break } } @@ -151,6 +171,18 @@ func TestAnalyzeStructuresDeduplicatesFlattenedPrivateBases(t *testing.T) { if !slices.Equal(gotFields, wantFields) { t.Fatalf("Public fields = %v, want %v", gotFields, wantFields) } + + if publicCycle == nil { + t.Fatal("PublicCycle structure not rendered") + } + gotFields = gotFields[:0] + for _, f := range publicCycle.Fields { + gotFields = append(gotFields, f.Name) + } + wantFields = []string{"CycleB", "CycleA", "CycleOwn"} + if !slices.Equal(gotFields, wantFields) { + t.Fatalf("PublicCycle fields = %v, want %v", gotFields, wantFields) + } } func TestHotOptionalField(t *testing.T) {