Skip to content

Commit 8457ea6

Browse files
authored
Reduce code size of lsproto package, use JSON roundtripping in fourslash (#4471)
1 parent fe14dff commit 8457ea6

13 files changed

Lines changed: 1762 additions & 23362 deletions

File tree

internal/fourslash/fourslash.go

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,8 @@ func (f *FourslashTest) handleServerRequest(_ context.Context, req *lsproto.Requ
242242
// Return current user preferences for each requested section.
243243
// The server requests multiple sections (js/ts, typescript, javascript, editor);
244244
// we return user preferences for "js/ts" and nil for others.
245-
params, ok := req.Params.(*lsproto.ConfigurationParams)
246-
if !ok || params == nil || params.Items == nil {
245+
params, err := lsproto.UnmarshalParams[*lsproto.ConfigurationParams](req)
246+
if err != nil || params == nil || params.Items == nil {
247247
return &lsproto.ResponseMessage{
248248
ID: req.ID,
249249
JSONRPC: req.JSONRPC,
@@ -1098,7 +1098,10 @@ func (f *FourslashTest) VerifyCompletions(t *testing.T, markerInput MarkerInput,
10981098
if item == nil {
10991099
t.Fatalf("Code action '%s' from source '%s' not found in completions.", expectedAction.Name, expectedAction.Source)
11001100
}
1101-
assert.Check(t, strings.Contains(*item.Detail, expectedAction.Description), "Completion item detail does not contain expected description.")
1101+
// Detail and AdditionalTextEdits for auto-import items are populated by
1102+
// completionItem/resolve, not in the initial completion list.
1103+
item = f.resolveCompletionItem(t, item)
1104+
assert.Check(t, item.Detail != nil && strings.Contains(*item.Detail, expectedAction.Description), "Completion item detail does not contain expected description.")
11021105
f.applyTextEdits(t, *item.AdditionalTextEdits)
11031106
assert.Equal(t, f.getScriptInfo(f.activeFilename).content, expectedAction.NewFileContent, fmt.Sprintf("File content after applying code action '%s' did not match expected content.", expectedAction.Name))
11041107
},
@@ -2532,7 +2535,7 @@ func (f *FourslashTest) verifyBaselineDefinitions(
25322535
} else if result.DefinitionLinks != nil {
25332536
var originRange *lsproto.Range
25342537
resultAsSpans = core.Map(*result.DefinitionLinks, func(link *lsproto.LocationLink) documentSpan {
2535-
if originRange != nil && originRange != link.OriginSelectionRange {
2538+
if originRange != nil && (link.OriginSelectionRange == nil || *originRange != *link.OriginSelectionRange) {
25362539
panic("multiple different origin ranges in definition links")
25372540
}
25382541
originRange = link.OriginSelectionRange
@@ -5487,18 +5490,22 @@ func (f *FourslashTest) VerifyBaselineDocumentSymbol(t *testing.T) {
54875490
}
54885491
result := sendRequest(t, f, lsproto.TextDocumentDocumentSymbolInfo, params)
54895492
uri := lsconv.FileNameToDocumentURI(f.activeFilename)
5490-
spansToSymbol := make(map[documentSpan]*lsproto.DocumentSymbol)
5493+
symbolBySpan := make(map[documentSpanKey]*lsproto.DocumentSymbol)
54915494
if result.DocumentSymbols != nil {
54925495
for _, symbol := range *result.DocumentSymbols {
5493-
collectDocumentSymbolSpans(uri, symbol, spansToSymbol)
5496+
collectDocumentSymbolSpans(uri, symbol, symbolBySpan)
54945497
}
54955498
}
5499+
spans := make([]documentSpan, 0, len(symbolBySpan))
5500+
for key, symbol := range symbolBySpan {
5501+
spans = append(spans, documentSpan{uri: key.uri, textSpan: key.textSpan, contextSpan: &symbol.Range})
5502+
}
54965503
f.addResultToBaseline(
54975504
t,
54985505
documentSymbolsCmd,
5499-
f.getBaselineForSpansWithFileContents(slices.Collect(maps.Keys(spansToSymbol)), baselineFourslashLocationsOptions{
5506+
f.getBaselineForSpansWithFileContents(spans, baselineFourslashLocationsOptions{
55005507
getLocationData: func(span documentSpan) string {
5501-
symbol := spansToSymbol[span]
5508+
symbol := symbolBySpan[documentSpanKey{uri: span.uri, textSpan: span.textSpan, contextSpan: *span.contextSpan}]
55025509
return fmt.Sprintf("{| name: %s, kind: %s |}", symbol.Name, symbol.Kind.String())
55035510
},
55045511
}),
@@ -5523,21 +5530,32 @@ func writeDocumentSymbolDetails(symbols []*lsproto.DocumentSymbol, indent int, b
55235530
func collectDocumentSymbolSpans(
55245531
uri lsproto.DocumentUri,
55255532
symbol *lsproto.DocumentSymbol,
5526-
spansToSymbol map[documentSpan]*lsproto.DocumentSymbol,
5533+
symbolBySpan map[documentSpanKey]*lsproto.DocumentSymbol,
55275534
) {
5528-
span := documentSpan{
5529-
uri: uri,
5530-
textSpan: symbol.SelectionRange,
5531-
contextSpan: &symbol.Range,
5535+
// Deduplicate by value rather than by the documentSpan key, which holds a pointer to
5536+
// the symbol's Range. The same logical symbol can be reached more than once
5537+
// (e.g. a merged declaration), and depending on transport those occurrences may
5538+
// be the same object (shared *Range) or independent copies (distinct *Range
5539+
// after a JSON round-trip). A value-based key collapses them consistently.
5540+
key := documentSpanKey{uri: uri, textSpan: symbol.SelectionRange, contextSpan: symbol.Range}
5541+
if _, ok := symbolBySpan[key]; !ok {
5542+
symbolBySpan[key] = symbol
55325543
}
5533-
spansToSymbol[span] = symbol
55345544
if symbol.Children != nil {
55355545
for _, child := range *symbol.Children {
5536-
collectDocumentSymbolSpans(uri, child, spansToSymbol)
5546+
collectDocumentSymbolSpans(uri, child, symbolBySpan)
55375547
}
55385548
}
55395549
}
55405550

5551+
// documentSpanKey is a value-comparable variant of documentSpan used to deduplicate
5552+
// document symbols regardless of pointer identity.
5553+
type documentSpanKey struct {
5554+
uri lsproto.DocumentUri
5555+
textSpan lsproto.Range
5556+
contextSpan lsproto.Range
5557+
}
5558+
55415559
// VerifyNumberOfErrorsInCurrentFile verifies that the current file has the expected number of errors.
55425560
func (f *FourslashTest) VerifyNumberOfErrorsInCurrentFile(t *testing.T, expectedCount int) {
55435561
diagnostics := f.getDiagnostics(t, f.activeFilename)

internal/ls/lsutil/userpreferences.go

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,48 @@ var typeSerializers = map[reflect.Type]func(any) any{
455455
return "auto"
456456
}
457457
},
458+
// These enums distinguish an unset zero value (e.g. "") from their effective
459+
// default (e.g. "auto"): the parser promotes unset/unknown input to the
460+
// non-zero default. Plain string serialization would therefore write "" for
461+
// an unset field and the parser would read it back as the non-zero default,
462+
// breaking round-tripping. Mirror the core.Tristate serializer above and omit
463+
// the unset value (return nil) so it decodes back to the zero value. (Enums
464+
// whose default already is their zero value, like the OrganizeImports* ones,
465+
// round-trip without this.)
466+
//
467+
// TODO: These three are the only parsers whose fallback is a non-zero value;
468+
// every other parser returns its zero value as the fallback. They should be
469+
// made consistent: change the parser fallback to return the zero value and
470+
// remove this serializer (relying on the default string serialization, which
471+
// already omits ""). The consumer must then treat the zero value as the
472+
// effective default. The two module-specifier enums are safe to convert (all
473+
// read sites already treat the "" zero identically to the promoted default).
474+
reflect.TypeFor[JsxAttributeCompletionStyle](): func(val any) any {
475+
// TODO: make consistent with other enums (see note above). Unlike the
476+
// module-specifier enums, the consumer in completions.go distinguishes
477+
// JsxAttributeCompletionStyleUnknown from ...Auto, so converting this one
478+
// requires updating that consumer to treat the zero value as "auto".
479+
if v := val.(JsxAttributeCompletionStyle); v != JsxAttributeCompletionStyleUnknown {
480+
return string(v)
481+
}
482+
return nil
483+
},
484+
reflect.TypeFor[modulespecifiers.ImportModuleSpecifierPreference](): func(val any) any {
485+
// TODO: make consistent with other enums (see note above): have the parser
486+
// return the zero value (None) as its fallback and drop this serializer.
487+
if v := val.(modulespecifiers.ImportModuleSpecifierPreference); v != "" {
488+
return string(v)
489+
}
490+
return nil
491+
},
492+
reflect.TypeFor[modulespecifiers.ImportModuleSpecifierEndingPreference](): func(val any) any {
493+
// TODO: make consistent with other enums (see note above): have the parser
494+
// return the zero value (None) as its fallback and drop this serializer.
495+
if v := val.(modulespecifiers.ImportModuleSpecifierEndingPreference); v != "" {
496+
return string(v)
497+
}
498+
return nil
499+
},
458500
}
459501

460502
// configPathParsers provides field-specific config value parsers that override the default
@@ -741,9 +783,21 @@ func serializeField(field reflect.Value) any {
741783
case reflect.Bool:
742784
return field.Bool()
743785
case reflect.Int:
744-
return int(field.Int())
786+
// Zero means "unset" for these preference fields. Omit it so a partial
787+
// config does not clobber defaults with zeros when round-tripped through
788+
// withConfig.
789+
i := field.Int()
790+
if i == 0 {
791+
return nil
792+
}
793+
return int(i)
745794
case reflect.String:
746-
return field.String()
795+
// Zero ("") means "unset"; omit it for the same reason as int above.
796+
s := field.String()
797+
if s == "" {
798+
return nil
799+
}
800+
return s
747801
case reflect.Slice:
748802
if field.IsNil() {
749803
return nil
@@ -770,28 +824,6 @@ func (p *UserPreferences) UnmarshalJSONFrom(dec *json.Decoder) error {
770824

771825
// --- Helper methods ---
772826

773-
// WithOverrides returns a copy of p with non-zero fields from overrides applied on top.
774-
// This is safe because all preference fields use types where zero = "not set":
775-
// Tristate (TSUnknown=0), int (0), string (""), slice (nil).
776-
func (p UserPreferences) WithOverrides(overrides UserPreferences) UserPreferences {
777-
mergeNonZeroFields(reflect.ValueOf(&p).Elem(), reflect.ValueOf(&overrides).Elem())
778-
return p
779-
}
780-
781-
func mergeNonZeroFields(dst, src reflect.Value) {
782-
for i := range dst.NumField() {
783-
srcField := src.Field(i)
784-
dstField := dst.Field(i)
785-
if srcField.Kind() == reflect.Struct {
786-
mergeNonZeroFields(dstField, srcField)
787-
continue
788-
}
789-
if !srcField.IsZero() {
790-
dstField.Set(srcField)
791-
}
792-
}
793-
}
794-
795827
func (p UserPreferences) ModuleSpecifierPreferences() modulespecifiers.UserPreferences {
796828
return modulespecifiers.UserPreferences{
797829
ImportModuleSpecifierPreference: p.ImportModuleSpecifierPreference,
@@ -821,11 +853,8 @@ func ParseUserPreferences(items map[string]any) UserPreferences {
821853
// Apply javascript, then typescript, then js/ts (highest precedence).
822854
for _, section := range []string{"javascript", "typescript", "js/ts"} {
823855
if item, ok := items[section]; ok && item != nil {
824-
switch settings := item.(type) {
825-
case map[string]any:
856+
if settings, ok := item.(map[string]any); ok {
826857
prefs = prefs.withConfig(settings)
827-
case UserPreferences:
828-
prefs = prefs.WithOverrides(settings)
829858
}
830859
}
831860
}

0 commit comments

Comments
 (0)