Skip to content

Refactor and improve generated LSP code#3299

Merged
jakebailey merged 20 commits intomainfrom
jabaile/lsp-gen-updates
Mar 31, 2026
Merged

Refactor and improve generated LSP code#3299
jakebailey merged 20 commits intomainfrom
jabaile/lsp-gen-updates

Conversation

@jakebailey
Copy link
Copy Markdown
Member

@jakebailey jakebailey commented Mar 30, 2026

Another omnibus of changes I've wanted to do for a while. Mainly this is focused on eliminating as much "double decode" as possible.

  • Notebook types have been removed from the generated protocol code. This is basically all unused and doesn't make sense for us.
  • RegisterOptions is now a typed struct keyed by registration method, with method derived automatically during marshal/unmarshal. Before you had to know to write the correct method. (see server.go).
  • Unions that can be distinguished by JSON token kind now dispatch via PeekKind and decode directly without buffering when unambiguous.
  • Unions that can be distinguished by a literal kind scan just enough to extract the discriminant,
  • Unions that can be distinguished by the presence of a unique required prop also scan just enough to check for the prop.
  • Ambiguous union variants are now sorted by specificity and warned about at generation time.
  • Fix a bug where we were not skipping over values in manual object decode.

The effect of this PR is that we never double decode anything coming from the client! A lot are distinguishable by kind, and otherwise, we can simply tokenize the input to look for a discriminant, bailing early and not actually decoding anything.

After this, 90% of all types don't need to do any extra decoding (maybe 20% need to do a bit of tokenizing), and the only ones that aren't are client -> server so are not critical.

It also turns out that one type in the spec, TextDocumentContentOptionsOrRegistrationOptions, is a union that cannot be distinguished at all! I'm not attempting to do anything about that in this PR.

Comment on lines 208 to +209
RegisterOptions: &lsproto.RegisterOptions{
DidChangeWatchedFiles: &lsproto.DidChangeWatchedFilesRegistrationOptions{
WorkspaceDidChangeWatchedFiles: &lsproto.DidChangeWatchedFilesRegistrationOptions{
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note the improvement here; we no longer need to make sure that the method and the options match. The lib determines the method from the options type itself.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the generated LSP protocol code to reduce or eliminate “double decode” during JSON unmarshalling, while also simplifying/typing registration options and removing unused notebook-related protocol surface.

Changes:

  • Reworks generated union unmarshalling to dispatch via PeekKind and minimal token scanning (discriminant field / required-prop presence) where possible.
  • Replaces Registration.method + registerOptions handling with a typed RegisterOptions struct keyed by method; method is derived during marshal/unmarshal.
  • Filters notebook-related types/requests/notifications/enums from the generated protocol model.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

File Description
internal/lsp/server.go Updates capability registrations to use typed RegisterOptions (method derived automatically).
internal/lsp/lsproto/lsp.go Adds JSON object key scanning helpers used by generated union dispatch; tweaks assertion helpers.
internal/lsp/lsproto/_generate/generate.mts Implements new generation logic for typed registration options + optimized union dispatch + notebook filtering.
internal/json/json.go Exposes NewDecoder wrapper used by lsproto’s lightweight JSON scanners.

type requiredProp bool
// jsonKeyCheck compares a raw JSON key token (including quotes) against a Go string.
func jsonKeyCheck(name []byte, key string) bool {
return len(name) == len(key)+2 && name[0] == '"' && string(name[1:len(name)-1]) == key
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsonKeyCheck converts the raw key token into a Go string (string(name[1:len-1])), which allocates on every key comparison. Since this function is used in the fast-path union dispatch scanners, it undermines the goal of minimizing overhead; consider doing a zero-allocation byte-by-byte compare against the ASCII key (or reading the key via a token API that returns the unescaped string).

Suggested change
return len(name) == len(key)+2 && name[0] == '"' && string(name[1:len(name)-1]) == key
// Expect: "key" with no escapes; this matches the previous implementation's assumptions.
if len(name) != len(key)+2 {
return false
}
if name[0] != '"' || name[len(name)-1] != '"' {
return false
}
for i := 0; i < len(key); i++ {
if name[i+1] != key[i] {
return false
}
}
return true

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not entirely opposed to the suggestion.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's untrue; Go knows to not copy bytes for this kind of expression: https://godbolt.org/z/7jq5nG84q

Note that it's just memequal.

Comment on lines +81 to +83
if err := dec.SkipValue(); err != nil {
return err
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug; we were continuing this loop, so this could technically have broken if the ignored key were the same as a prop!

Comment on lines +24389 to 24406
switch dec.PeekKind() {
case '0':
var v int32
if err := json.UnmarshalDecode(dec, &v); err != nil {
return err
}
o.Integer = &v
return nil
}
var vString string
if err := json.Unmarshal(data, &vString); err == nil {
o.String = &vString
case '"':
var v string
if err := json.UnmarshalDecode(dec, &v); err != nil {
return err
}
o.String = &v
return nil
default:
return fmt.Errorf("invalid IntegerOrString: expected number, string, got %v", dec.PeekKind())
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example of peeking the kind. This is IntegerOrString, we don't need to try integer first, just ask the decoder for the token kind.

Comment on lines +24570 to 24599
switch string(jsonObjectRawField(data, "kind")) {
case `"rename"`:
var v RenameFile
if err := json.Unmarshal(data, &v); err != nil {
return err
}
o.RenameFile = &v
return nil
}
var vCreateFile CreateFile
if err := json.Unmarshal(data, &vCreateFile); err == nil {
o.CreateFile = &vCreateFile
case `"create"`:
var v CreateFile
if err := json.Unmarshal(data, &v); err != nil {
return err
}
o.CreateFile = &v
return nil
}
var vRenameFile RenameFile
if err := json.Unmarshal(data, &vRenameFile); err == nil {
o.RenameFile = &vRenameFile
case `"delete"`:
var v DeleteFile
if err := json.Unmarshal(data, &v); err != nil {
return err
}
o.DeleteFile = &v
return nil
}
var vDeleteFile DeleteFile
if err := json.Unmarshal(data, &vDeleteFile); err == nil {
o.DeleteFile = &vDeleteFile
default:
var vTextDocumentEdit TextDocumentEdit
if err := json.Unmarshal(data, &vTextDocumentEdit); err != nil {
return err
}
o.TextDocumentEdit = &vTextDocumentEdit
return nil
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this file event, we can peek ahead looking for kind.

Comment on lines +25082 to 25097
switch jsonObjectHasKey(data, "insert", "range") {
case 1: // insert
var v InsertReplaceEdit
if err := json.Unmarshal(data, &v); err != nil {
return err
}
o.InsertReplaceEdit = &v
return nil
}
var vInsertReplaceEdit InsertReplaceEdit
if err := json.Unmarshal(data, &vInsertReplaceEdit); err == nil {
o.InsertReplaceEdit = &vInsertReplaceEdit
case 2: // range
var v TextEdit
if err := json.Unmarshal(data, &v); err != nil {
return err
}
o.TextEdit = &v
return nil
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example of discriminating by property existence.

o.Integer = new(int32)
return json.UnmarshalDecode(dec, o.Integer)
case '"':
o.String = new(string)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need the new(string) if you're immediately calling json.UnmarshalDecode onto o.String?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just in case an error condition takes place?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's o.String, not &o.String; we are passing in the pointer we just allocated. Notably the point here is that for say string, we do not want to allow null to be parsed. If we passed in &o.String, we would be passing in **string, which the decoder would happily stick nil into for a null input.

@jakebailey jakebailey added this pull request to the merge queue Mar 31, 2026
Merged via the queue into main with commit f367afa Mar 31, 2026
21 checks passed
@jakebailey jakebailey deleted the jabaile/lsp-gen-updates branch March 31, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants