package fourslash import ( "fmt" "io" "maps" "slices" "strings" "testing" "unicode/utf8" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/bundled" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/core" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/ls" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/lsp" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/lsp/lsproto" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/project" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/repo" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/stringutil" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/baseline" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/harnessutil" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs/vfstest" "github.com/go-json-experiment/json" "github.com/google/go-cmp/cmp" "gotest.tools/v3/assert" ) type FourslashTest struct { server *lsp.Server in *lspWriter out *lspReader id int32 vfs vfs.FS testData *TestData // !!! consolidate test files from test data and script info baselines map[string]*strings.Builder rangesByText *collections.MultiMap[string, *RangeMarker] scriptInfos map[string]*scriptInfo converters *ls.Converters currentCaretPosition lsproto.Position lastKnownMarkerName *string activeFilename string selectionEnd *lsproto.Position } type scriptInfo struct { fileName string content string lineMap *ls.LSPLineMap version int32 } func newScriptInfo(fileName string, content string) *scriptInfo { return &scriptInfo{ fileName: fileName, content: content, lineMap: ls.ComputeLSPLineStarts(content), version: 1, } } func (s *scriptInfo) editContent(start int, end int, newText string) { s.content = s.content[:start] + newText + s.content[end:] s.lineMap = ls.ComputeLSPLineStarts(s.content) s.version++ } func (s *scriptInfo) Text() string { return s.content } func (s *scriptInfo) FileName() string { return s.fileName } type lspReader struct { c <-chan *lsproto.Message } func (r *lspReader) Read() (*lsproto.Message, error) { msg, ok := <-r.c if !ok { return nil, io.EOF } return msg, nil } type lspWriter struct { c chan<- *lsproto.Message } func (w *lspWriter) Write(msg *lsproto.Message) error { w.c <- msg return nil } func (r *lspWriter) Close() { close(r.c) } var ( _ lsp.Reader = (*lspReader)(nil) _ lsp.Writer = (*lspWriter)(nil) ) func newLSPPipe() (*lspReader, *lspWriter) { c := make(chan *lsproto.Message, 100) return &lspReader{c: c}, &lspWriter{c: c} } const rootDir = "/" var parseCache = project.ParseCache{ Options: project.ParseCacheOptions{ DisableDeletion: true, }, } func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, content string) *FourslashTest { repo.SkipIfNoTypeScriptSubmodule(t) if !bundled.Embedded { // Without embedding, we'd need to read all of the lib files out from disk into the MapFS. // Just skip this for now. t.Skip("bundled files are not embedded") } fileName := getBaseFileNameFromTest(t) + tspath.ExtensionTs testfs := make(map[string]string) scriptInfos := make(map[string]*scriptInfo) testData := ParseTestData(t, content, fileName) for _, file := range testData.Files { filePath := tspath.GetNormalizedAbsolutePath(file.fileName, rootDir) testfs[filePath] = file.Content scriptInfos[filePath] = newScriptInfo(filePath, file.Content) } compilerOptions := &core.CompilerOptions{ SkipDefaultLibCheck: core.TSTrue, } harnessutil.SetCompilerOptionsFromTestConfig(t, testData.GlobalOptions, compilerOptions, rootDir) inputReader, inputWriter := newLSPPipe() outputReader, outputWriter := newLSPPipe() fs := bundled.WrapFS(vfstest.FromMap(testfs, true /*useCaseSensitiveFileNames*/)) var err strings.Builder server := lsp.NewServer(&lsp.ServerOptions{ In: inputReader, Out: outputWriter, Err: &err, Cwd: "/", FS: fs, DefaultLibraryPath: bundled.LibPath(), ParseCache: &parseCache, }) go func() { defer func() { outputWriter.Close() }() err := server.Run() if err != nil { t.Error("server error:", err) } }() converters := ls.NewConverters(lsproto.PositionEncodingKindUTF8, func(fileName string) *ls.LSPLineMap { scriptInfo, ok := scriptInfos[fileName] if !ok { return nil } return scriptInfo.lineMap }) f := &FourslashTest{ server: server, in: inputWriter, out: outputReader, testData: &testData, vfs: fs, scriptInfos: scriptInfos, converters: converters, baselines: make(map[string]*strings.Builder), } // !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support // !!! replace with a proper request *after initialize* f.server.SetCompilerOptionsForInferredProjects(t.Context(), compilerOptions) f.initialize(t, capabilities) for _, file := range testData.Files { f.openFile(t, file.fileName) } f.activeFilename = f.testData.Files[0].fileName t.Cleanup(func() { inputWriter.Close() f.verifyBaselines(t) }) return f } func getBaseFileNameFromTest(t *testing.T) string { name := strings.TrimPrefix(t.Name(), "Test") return stringutil.LowerFirstChar(name) } func (f *FourslashTest) nextID() int32 { id := f.id f.id++ return id } func (f *FourslashTest) initialize(t *testing.T, capabilities *lsproto.ClientCapabilities) { params := &lsproto.InitializeParams{ Locale: ptrTo("en-US"), } params.Capabilities = getCapabilitiesWithDefaults(capabilities) // !!! check for errors? sendRequest(t, f, lsproto.InitializeInfo, params) sendNotification(t, f, lsproto.InitializedInfo, &lsproto.InitializedParams{}) } var ( ptrTrue = ptrTo(true) defaultCompletionCapabilities = &lsproto.CompletionClientCapabilities{ CompletionItem: &lsproto.ClientCompletionItemOptions{ SnippetSupport: ptrTrue, CommitCharactersSupport: ptrTrue, PreselectSupport: ptrTrue, LabelDetailsSupport: ptrTrue, InsertReplaceSupport: ptrTrue, }, CompletionList: &lsproto.CompletionListCapabilities{ ItemDefaults: &[]string{"commitCharacters", "editRange"}, }, } ) func getCapabilitiesWithDefaults(capabilities *lsproto.ClientCapabilities) *lsproto.ClientCapabilities { var capabilitiesWithDefaults lsproto.ClientCapabilities if capabilities != nil { capabilitiesWithDefaults = *capabilities } capabilitiesWithDefaults.General = &lsproto.GeneralClientCapabilities{ PositionEncodings: &[]lsproto.PositionEncodingKind{lsproto.PositionEncodingKindUTF8}, } if capabilitiesWithDefaults.TextDocument == nil { capabilitiesWithDefaults.TextDocument = &lsproto.TextDocumentClientCapabilities{} } if capabilitiesWithDefaults.TextDocument.Completion == nil { capabilitiesWithDefaults.TextDocument.Completion = defaultCompletionCapabilities } return &capabilitiesWithDefaults } func sendRequest[Params, Resp any](t *testing.T, f *FourslashTest, info lsproto.RequestInfo[Params, Resp], params Params) (*lsproto.Message, Resp, bool) { id := f.nextID() req := lsproto.NewRequestMessage( info.Method, lsproto.NewID(lsproto.IntegerOrString{Integer: &id}), params, ) f.writeMsg(t, req.Message()) resp := f.readMsg(t) if resp == nil { return nil, *new(Resp), false } result, ok := resp.AsResponse().Result.(Resp) return resp, result, ok } func sendNotification[Params any](t *testing.T, f *FourslashTest, info lsproto.NotificationInfo[Params], params Params) { notification := lsproto.NewNotificationMessage( info.Method, params, ) f.writeMsg(t, notification.Message()) } func (f *FourslashTest) writeMsg(t *testing.T, msg *lsproto.Message) { assert.NilError(t, json.MarshalWrite(io.Discard, msg), "failed to encode message as JSON") if err := f.in.Write(msg); err != nil { t.Fatalf("failed to write message: %v", err) } } func (f *FourslashTest) readMsg(t *testing.T) *lsproto.Message { // !!! filter out response by id etc msg, err := f.out.Read() if err != nil { t.Fatalf("failed to read message: %v", err) } assert.NilError(t, json.MarshalWrite(io.Discard, msg), "failed to encode message as JSON") return msg } func (f *FourslashTest) GoToMarkerOrRange(t *testing.T, markerOrRange MarkerOrRange) { f.goToMarker(t, markerOrRange) } func (f *FourslashTest) GoToMarker(t *testing.T, markerName string) { marker, ok := f.testData.MarkerPositions[markerName] if !ok { t.Fatalf("Marker '%s' not found", markerName) } f.goToMarker(t, marker) } func (f *FourslashTest) goToMarker(t *testing.T, markerOrRange MarkerOrRange) { f.ensureActiveFile(t, markerOrRange.FileName()) f.goToPosition(t, markerOrRange.LSPos()) f.lastKnownMarkerName = markerOrRange.GetName() } func (f *FourslashTest) GoToEOF(t *testing.T) { script := f.getScriptInfo(f.activeFilename) pos := len(script.content) LSPPos := f.converters.PositionToLineAndCharacter(script, core.TextPos(pos)) f.goToPosition(t, LSPPos) } func (f *FourslashTest) GoToBOF(t *testing.T) { f.goToPosition(t, lsproto.Position{Line: 0, Character: 0}) } func (f *FourslashTest) GoToPosition(t *testing.T, position int) { script := f.getScriptInfo(f.activeFilename) LSPPos := f.converters.PositionToLineAndCharacter(script, core.TextPos(position)) f.goToPosition(t, LSPPos) } func (f *FourslashTest) goToPosition(t *testing.T, position lsproto.Position) { f.currentCaretPosition = position f.selectionEnd = nil } func (f *FourslashTest) GoToEachMarker(t *testing.T, markerNames []string, action func(marker *Marker, index int)) { var markers []*Marker if len(markers) == 0 { markers = f.Markers() } else { markers = make([]*Marker, 0, len(markerNames)) for _, name := range markerNames { marker, ok := f.testData.MarkerPositions[name] if !ok { t.Fatalf("Marker '%s' not found", name) } markers = append(markers, marker) } } for i, marker := range markers { f.goToMarker(t, marker) action(marker, i) } } func (f *FourslashTest) GoToEachRange(t *testing.T, action func(t *testing.T, rangeMarker *RangeMarker)) { ranges := f.Ranges() for _, rangeMarker := range ranges { f.goToPosition(t, rangeMarker.LSRange.Start) action(t, rangeMarker) } } func (f *FourslashTest) GoToRangeStart(t *testing.T, rangeMarker *RangeMarker) { f.openFile(t, rangeMarker.FileName()) f.goToPosition(t, rangeMarker.LSRange.Start) } func (f *FourslashTest) GoToSelect(t *testing.T, startMarkerName string, endMarkerName string) { startMarker := f.testData.MarkerPositions[startMarkerName] if startMarker == nil { t.Fatalf("Start marker '%s' not found", startMarkerName) } endMarker := f.testData.MarkerPositions[endMarkerName] if endMarker == nil { t.Fatalf("End marker '%s' not found", endMarkerName) } if startMarker.FileName() != endMarker.FileName() { t.Fatalf("Markers '%s' and '%s' are in different files", startMarkerName, endMarkerName) } f.ensureActiveFile(t, startMarker.FileName()) f.goToPosition(t, startMarker.LSPosition) f.selectionEnd = &endMarker.LSPosition } func (f *FourslashTest) GoToSelectRange(t *testing.T, rangeMarker *RangeMarker) { f.GoToRangeStart(t, rangeMarker) f.selectionEnd = &rangeMarker.LSRange.End } func (f *FourslashTest) GoToFile(t *testing.T, filename string) { filename = tspath.GetNormalizedAbsolutePath(filename, rootDir) f.openFile(t, filename) } func (f *FourslashTest) GoToFileNumber(t *testing.T, index int) { if index < 0 || index >= len(f.testData.Files) { t.Fatalf("File index %d out of range (0-%d)", index, len(f.testData.Files)-1) } filename := f.testData.Files[index].fileName f.openFile(t, filename) } func (f *FourslashTest) Markers() []*Marker { return f.testData.Markers } func (f *FourslashTest) MarkerNames() []string { return core.MapFiltered(f.testData.Markers, func(marker *Marker) (string, bool) { if marker.Name == nil { return "", false } return *marker.Name, true }) } func (f *FourslashTest) Ranges() []*RangeMarker { return f.testData.Ranges } func (f *FourslashTest) ensureActiveFile(t *testing.T, filename string) { if f.activeFilename != filename { f.openFile(t, filename) } } func (f *FourslashTest) openFile(t *testing.T, filename string) { script := f.getScriptInfo(filename) if script == nil { t.Fatalf("File %s not found in test data", filename) } f.activeFilename = filename sendNotification(t, f, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ TextDocument: &lsproto.TextDocumentItem{ Uri: ls.FileNameToDocumentURI(filename), LanguageId: getLanguageKind(filename), Text: script.content, }, }) } func getLanguageKind(filename string) lsproto.LanguageKind { if tspath.FileExtensionIsOneOf( filename, []string{ tspath.ExtensionTs, tspath.ExtensionMts, tspath.ExtensionCts, tspath.ExtensionDmts, tspath.ExtensionDcts, tspath.ExtensionDts, }) { return lsproto.LanguageKindTypeScript } if tspath.FileExtensionIsOneOf(filename, []string{tspath.ExtensionJs, tspath.ExtensionMjs, tspath.ExtensionCjs}) { return lsproto.LanguageKindJavaScript } if tspath.FileExtensionIs(filename, tspath.ExtensionJsx) { return lsproto.LanguageKindJavaScriptReact } if tspath.FileExtensionIs(filename, tspath.ExtensionTsx) { return lsproto.LanguageKindTypeScriptReact } if tspath.FileExtensionIs(filename, tspath.ExtensionJson) { return lsproto.LanguageKindJSON } return lsproto.LanguageKindTypeScript // !!! should we error in this case? } type CompletionsExpectedList struct { IsIncomplete bool ItemDefaults *CompletionsExpectedItemDefaults Items *CompletionsExpectedItems UserPreferences *ls.UserPreferences // !!! allow user preferences in fourslash } type Ignored = struct{} // *EditRange | Ignored type ExpectedCompletionEditRange = any type EditRange struct { Insert *RangeMarker Replace *RangeMarker } type CompletionsExpectedItemDefaults struct { CommitCharacters *[]string EditRange ExpectedCompletionEditRange } // *lsproto.CompletionItem | string type CompletionsExpectedItem = any type CompletionsExpectedItems struct { Includes []CompletionsExpectedItem Excludes []string Exact []CompletionsExpectedItem Unsorted []CompletionsExpectedItem } // string | *Marker | []string | []*Marker type MarkerInput = any // !!! user preferences param // !!! completion context param func (f *FourslashTest) VerifyCompletions(t *testing.T, markerInput MarkerInput, expected *CompletionsExpectedList) { switch marker := markerInput.(type) { case string: f.GoToMarker(t, marker) f.verifyCompletionsWorker(t, expected) case *Marker: f.goToMarker(t, marker) f.verifyCompletionsWorker(t, expected) case []string: for _, markerName := range marker { f.GoToMarker(t, markerName) f.verifyCompletionsWorker(t, expected) } case []*Marker: for _, marker := range marker { f.goToMarker(t, marker) f.verifyCompletionsWorker(t, expected) } case nil: f.verifyCompletionsWorker(t, expected) default: t.Fatalf("Invalid marker input type: %T. Expected string, *Marker, []string, or []*Marker.", markerInput) } } func (f *FourslashTest) verifyCompletionsWorker(t *testing.T, expected *CompletionsExpectedList) { prefix := f.getCurrentPositionPrefix() params := &lsproto.CompletionParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, Context: &lsproto.CompletionContext{}, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentCompletionInfo, params) if resMsg == nil { t.Fatalf(prefix+"Nil response received for completion request", f.lastKnownMarkerName) } if !resultOk { t.Fatalf(prefix+"Unexpected response type for completion request: %T", resMsg.AsResponse().Result) } f.verifyCompletionsResult(t, result.List, expected, prefix) } func (f *FourslashTest) verifyCompletionsResult( t *testing.T, actual *lsproto.CompletionList, expected *CompletionsExpectedList, prefix string, ) { if actual == nil { if !isEmptyExpectedList(expected) { t.Fatal(prefix + "Expected completion list but got nil.") } return } else if expected == nil { // !!! cmp.Diff(actual, nil) should probably be a .String() call here and elswhere t.Fatalf(prefix+"Expected nil completion list but got non-nil: %s", cmp.Diff(actual, nil)) } assert.Equal(t, actual.IsIncomplete, expected.IsIncomplete, prefix+"IsIncomplete mismatch") verifyCompletionsItemDefaults(t, actual.ItemDefaults, expected.ItemDefaults, prefix+"ItemDefaults mismatch: ") f.verifyCompletionsItems(t, prefix, actual.Items, expected.Items) } func isEmptyExpectedList(expected *CompletionsExpectedList) bool { return expected == nil || (len(expected.Items.Exact) == 0 && len(expected.Items.Includes) == 0 && len(expected.Items.Excludes) == 0) } func verifyCompletionsItemDefaults(t *testing.T, actual *lsproto.CompletionItemDefaults, expected *CompletionsExpectedItemDefaults, prefix string) { if actual == nil { if expected == nil { return } t.Fatalf(prefix+"Expected non-nil completion item defaults but got nil: %s", cmp.Diff(actual, nil)) } if expected == nil { t.Fatalf(prefix+"Expected nil completion item defaults but got non-nil: %s", cmp.Diff(actual, nil)) } assertDeepEqual(t, actual.CommitCharacters, expected.CommitCharacters, prefix+"CommitCharacters mismatch:") switch editRange := expected.EditRange.(type) { case *EditRange: if actual.EditRange == nil { t.Fatal(prefix + "Expected non-nil EditRange but got nil") } expectedInsert := editRange.Insert.LSRange expectedReplace := editRange.Replace.LSRange assertDeepEqual( t, actual.EditRange, &lsproto.RangeOrEditRangeWithInsertReplace{ EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ Insert: expectedInsert, Replace: expectedReplace, }, }, prefix+"EditRange mismatch:") case nil: if actual.EditRange != nil { t.Fatalf(prefix+"Expected nil EditRange but got non-nil: %s", cmp.Diff(actual.EditRange, nil)) } case Ignored: default: t.Fatalf(prefix+"Expected EditRange to be *EditRange or Ignored, got %T", editRange) } } func (f *FourslashTest) verifyCompletionsItems(t *testing.T, prefix string, actual []*lsproto.CompletionItem, expected *CompletionsExpectedItems) { if expected.Exact != nil { if expected.Includes != nil { t.Fatal(prefix + "Expected exact completion list but also specified 'includes'.") } if expected.Excludes != nil { t.Fatal(prefix + "Expected exact completion list but also specified 'excludes'.") } if expected.Unsorted != nil { t.Fatal(prefix + "Expected exact completion list but also specified 'unsorted'.") } if len(actual) != len(expected.Exact) { t.Fatalf(prefix+"Expected %d exact completion items but got %d: %s", len(expected.Exact), len(actual), cmp.Diff(actual, expected.Exact)) } if len(actual) > 0 { f.verifyCompletionsAreExactly(t, prefix, actual, expected.Exact) } return } nameToActualItem := make(map[string]*lsproto.CompletionItem) for _, item := range actual { nameToActualItem[item.Label] = item } if expected.Unsorted != nil { if expected.Includes != nil { t.Fatal(prefix + "Expected unsorted completion list but also specified 'includes'.") } if expected.Excludes != nil { t.Fatal(prefix + "Expected unsorted completion list but also specified 'excludes'.") } for _, item := range expected.Unsorted { switch item := item.(type) { case string: _, ok := nameToActualItem[item] if !ok { t.Fatalf("%sLabel '%s' not found in actual items. Actual items: %s", prefix, item, cmp.Diff(actual, nil)) } delete(nameToActualItem, item) case *lsproto.CompletionItem: actualItem, ok := nameToActualItem[item.Label] if !ok { t.Fatalf("%sLabel '%s' not found in actual items. Actual items: %s", prefix, item.Label, cmp.Diff(actual, nil)) } delete(nameToActualItem, item.Label) f.verifyCompletionItem(t, prefix+"Includes completion item mismatch for label "+item.Label+": ", actualItem, item) default: t.Fatalf("%sExpected completion item to be a string or *lsproto.CompletionItem, got %T", prefix, item) } } if len(expected.Unsorted) != len(actual) { unmatched := slices.Collect(maps.Keys(nameToActualItem)) t.Fatalf("%sAdditional completions found but not included in 'unsorted': %s", prefix, strings.Join(unmatched, "\n")) } return } if expected.Includes != nil { for _, item := range expected.Includes { switch item := item.(type) { case string: _, ok := nameToActualItem[item] if !ok { t.Fatalf("%sLabel '%s' not found in actual items. Actual items: %s", prefix, item, cmp.Diff(actual, nil)) } case *lsproto.CompletionItem: actualItem, ok := nameToActualItem[item.Label] if !ok { t.Fatalf("%sLabel '%s' not found in actual items. Actual items: %s", prefix, item.Label, cmp.Diff(actual, nil)) } f.verifyCompletionItem(t, prefix+"Includes completion item mismatch for label "+item.Label+": ", actualItem, item) default: t.Fatalf("%sExpected completion item to be a string or *lsproto.CompletionItem, got %T", prefix, item) } } } for _, exclude := range expected.Excludes { if _, ok := nameToActualItem[exclude]; ok { t.Fatalf("%sLabel '%s' should not be in actual items but was found. Actual items: %s", prefix, exclude, cmp.Diff(actual, nil)) } } } func (f *FourslashTest) verifyCompletionsAreExactly(t *testing.T, prefix string, actual []*lsproto.CompletionItem, expected []CompletionsExpectedItem) { // Verify labels first assertDeepEqual(t, core.Map(actual, func(item *lsproto.CompletionItem) string { return item.Label }), core.Map(expected, func(item CompletionsExpectedItem) string { return getExpectedLabel(t, item) }), prefix+"Labels mismatch") for i, actualItem := range actual { switch expectedItem := expected[i].(type) { case string: continue // already checked labels case *lsproto.CompletionItem: f.verifyCompletionItem(t, prefix+"Completion item mismatch for label "+actualItem.Label, actualItem, expectedItem) } } } var completionIgnoreOpts = cmp.FilterPath( func(p cmp.Path) bool { switch p.Last().String() { case ".Kind", ".SortText", ".Data": return true default: return false } }, cmp.Ignore(), ) func (f *FourslashTest) verifyCompletionItem(t *testing.T, prefix string, actual *lsproto.CompletionItem, expected *lsproto.CompletionItem) { if expected.Detail != nil || expected.Documentation != nil { resMsg, result, resultOk := sendRequest(t, f, lsproto.CompletionItemResolveInfo, actual) if resMsg == nil { t.Fatal(prefix + "Expected non-nil response for completion item resolve, got nil") } if !resultOk { t.Fatalf(prefix+"Unexpected response type for completion item resolve: %T", resMsg.AsResponse().Result) } actual = result } assertDeepEqual(t, actual, expected, prefix, completionIgnoreOpts) if expected.Kind != nil { assertDeepEqual(t, actual.Kind, expected.Kind, prefix+" Kind mismatch") } assertDeepEqual(t, actual.SortText, core.OrElse(expected.SortText, ptrTo(string(ls.SortTextLocationPriority))), prefix+" SortText mismatch") } func getExpectedLabel(t *testing.T, item CompletionsExpectedItem) string { switch item := item.(type) { case string: return item case *lsproto.CompletionItem: return item.Label default: t.Fatalf("Expected completion item to be a string or *lsproto.CompletionItem, got %T", item) return "" } } func assertDeepEqual(t *testing.T, actual any, expected any, prefix string, opts ...cmp.Option) { t.Helper() diff := cmp.Diff(actual, expected, opts...) if diff != "" { t.Fatalf("%s:\n%s", prefix, diff) } } func (f *FourslashTest) VerifyBaselineFindAllReferences( t *testing.T, markers ...string, ) { referenceLocations := f.lookupMarkersOrGetRanges(t, markers) for _, markerOrRange := range referenceLocations { // worker in `baselineEachMarkerOrRange` f.GoToMarkerOrRange(t, markerOrRange) params := &lsproto.ReferenceParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, Context: &lsproto.ReferenceContext{}, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentReferencesInfo, params) if resMsg == nil { if f.lastKnownMarkerName == nil { t.Fatalf("Nil response received for references request at pos %v", f.currentCaretPosition) } else { t.Fatalf("Nil response received for references request at marker '%s'", *f.lastKnownMarkerName) } } if !resultOk { if f.lastKnownMarkerName == nil { t.Fatalf("Unexpected references response type at pos %v: %T", f.currentCaretPosition, resMsg.AsResponse().Result) } else { t.Fatalf("Unexpected references response type at marker '%s': %T", *f.lastKnownMarkerName, resMsg.AsResponse().Result) } } f.addResultToBaseline(t, "findAllReferences", f.getBaselineForLocationsWithFileContents(*result.Locations, baselineFourslashLocationsOptions{ marker: markerOrRange, markerName: "/*FIND ALL REFS*/", })) } } func (f *FourslashTest) VerifyBaselineGoToDefinition( t *testing.T, markers ...string, ) { referenceLocations := f.lookupMarkersOrGetRanges(t, markers) for _, markerOrRange := range referenceLocations { // worker in `baselineEachMarkerOrRange` f.GoToMarkerOrRange(t, markerOrRange) params := &lsproto.DefinitionParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentDefinitionInfo, params) if resMsg == nil { if f.lastKnownMarkerName == nil { t.Fatalf("Nil response received for definition request at pos %v", f.currentCaretPosition) } else { t.Fatalf("Nil response received for definition request at marker '%s'", *f.lastKnownMarkerName) } } if !resultOk { if f.lastKnownMarkerName == nil { t.Fatalf("Unexpected definition response type at pos %v: %T", f.currentCaretPosition, resMsg.AsResponse().Result) } else { t.Fatalf("Unexpected definition response type at marker '%s': %T", *f.lastKnownMarkerName, resMsg.AsResponse().Result) } } var resultAsLocations []lsproto.Location if result.Locations != nil { resultAsLocations = *result.Locations } else if result.Location != nil { resultAsLocations = []lsproto.Location{*result.Location} } else if result.DefinitionLinks != nil { t.Fatalf("Unexpected definition response type at marker '%s': %T", *f.lastKnownMarkerName, result.DefinitionLinks) } f.addResultToBaseline(t, "goToDefinition", f.getBaselineForLocationsWithFileContents(resultAsLocations, baselineFourslashLocationsOptions{ marker: markerOrRange, markerName: "/*GOTO DEF*/", })) } } func (f *FourslashTest) VerifyBaselineHover(t *testing.T) { markersAndItems := core.MapFiltered(f.Markers(), func(marker *Marker) (markerAndItem[*lsproto.Hover], bool) { if marker.Name == nil { return markerAndItem[*lsproto.Hover]{}, false } params := &lsproto.HoverParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: marker.LSPosition, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentHoverInfo, params) if resMsg == nil { t.Fatalf(f.getCurrentPositionPrefix()+"Nil response received for quick info request", f.lastKnownMarkerName) } if !resultOk { t.Fatalf(f.getCurrentPositionPrefix()+"Unexpected response type for quick info request: %T", resMsg.AsResponse().Result) } return markerAndItem[*lsproto.Hover]{Marker: marker, Item: result.Hover}, true }) getRange := func(item *lsproto.Hover) *lsproto.Range { if item == nil || item.Range == nil { return nil } return item.Range } getTooltipLines := func(item, _prev *lsproto.Hover) []string { var result []string if item.Contents.MarkupContent != nil { result = strings.Split(item.Contents.MarkupContent.Value, "\n") } if item.Contents.String != nil { result = strings.Split(*item.Contents.String, "\n") } if item.Contents.MarkedStringWithLanguage != nil { result = appendLinesForMarkedStringWithLanguage(result, item.Contents.MarkedStringWithLanguage) } if item.Contents.MarkedStrings != nil { for _, ms := range *item.Contents.MarkedStrings { if ms.MarkedStringWithLanguage != nil { result = appendLinesForMarkedStringWithLanguage(result, ms.MarkedStringWithLanguage) } else { result = append(result, *ms.String) } } } return result } f.addResultToBaseline(t, "QuickInfo", annotateContentWithTooltips(t, f, markersAndItems, "quickinfo", getRange, getTooltipLines)) if jsonStr, err := core.StringifyJson(markersAndItems, "", " "); err == nil { f.writeToBaseline("QuickInfo", jsonStr) } else { t.Fatalf("Failed to stringify markers and items for baseline: %v", err) } } func appendLinesForMarkedStringWithLanguage(result []string, ms *lsproto.MarkedStringWithLanguage) []string { result = append(result, "```"+ms.Language) result = append(result, ms.Value) result = append(result, "```") return result } func (f *FourslashTest) VerifyBaselineSignatureHelp(t *testing.T) { markersAndItems := core.MapFiltered(f.Markers(), func(marker *Marker) (markerAndItem[*lsproto.SignatureHelp], bool) { if marker.Name == nil { return markerAndItem[*lsproto.SignatureHelp]{}, false } params := &lsproto.SignatureHelpParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: marker.LSPosition, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params) if resMsg == nil { t.Fatalf(f.getCurrentPositionPrefix()+"Nil response received for signature help request", f.lastKnownMarkerName) } if !resultOk { t.Fatalf(f.getCurrentPositionPrefix()+"Unexpected response type for signature help request: %T", resMsg.AsResponse().Result) } return markerAndItem[*lsproto.SignatureHelp]{Marker: marker, Item: result.SignatureHelp}, true }) getRange := func(item *lsproto.SignatureHelp) *lsproto.Range { // SignatureHelp doesn't have a range like hover does return nil } getTooltipLines := func(item, _prev *lsproto.SignatureHelp) []string { if item == nil || len(item.Signatures) == 0 { return []string{"No signature help available"} } // Show active signature if specified, otherwise first signature activeSignature := 0 if item.ActiveSignature != nil && int(*item.ActiveSignature) < len(item.Signatures) { activeSignature = int(*item.ActiveSignature) } sig := item.Signatures[activeSignature] // Build signature display signatureLine := sig.Label activeParamLine := "" // Show active parameter if specified, and the signature text. if item.ActiveParameter != nil && sig.Parameters != nil { activeParamIndex := int(*item.ActiveParameter.Uinteger) if activeParamIndex >= 0 && activeParamIndex < len(*sig.Parameters) { activeParam := (*sig.Parameters)[activeParamIndex] // Get the parameter label and bold the // parameter text within the original string. activeParamLabel := "" if activeParam.Label.String != nil { activeParamLabel = *activeParam.Label.String } else if activeParam.Label.Tuple != nil { activeParamLabel = signatureLine[(*activeParam.Label.Tuple)[0]:(*activeParam.Label.Tuple)[1]] } else { t.Fatal("Unsupported param label kind.") } signatureLine = strings.Replace(signatureLine, activeParamLabel, "**"+activeParamLabel+"**", 1) if activeParam.Documentation != nil { if activeParam.Documentation.MarkupContent != nil { activeParamLine = activeParam.Documentation.MarkupContent.Value } else if activeParam.Documentation.String != nil { activeParamLine = *activeParam.Documentation.String } activeParamLine = fmt.Sprintf("- `%s`: %s", activeParamLabel, activeParamLine) } } } result := make([]string, 0, 16) result = append(result, signatureLine) if activeParamLine != "" { result = append(result, activeParamLine) } // ORIGINALLY we would "only display signature documentation on the last argument when multiple arguments are marked". // !!! // Note that this is harder than in Strada, because LSP signature help has no concept of // applicable spans. if sig.Documentation != nil { if sig.Documentation.MarkupContent != nil { result = append(result, strings.Split(sig.Documentation.MarkupContent.Value, "\n")...) } else if sig.Documentation.String != nil { result = append(result, strings.Split(*sig.Documentation.String, "\n")...) } else { t.Fatal("Unsupported documentation format.") } } return result } f.addResultToBaseline(t, "SignatureHelp", annotateContentWithTooltips(t, f, markersAndItems, "signaturehelp", getRange, getTooltipLines)) if jsonStr, err := core.StringifyJson(markersAndItems, "", " "); err == nil { f.writeToBaseline("SignatureHelp", jsonStr) } else { t.Fatalf("Failed to stringify markers and items for baseline: %v", err) } } func (f *FourslashTest) VerifyBaselineDocumentHighlights( t *testing.T, preferences *ls.UserPreferences, markerOrRangeOrNames ...MarkerOrRangeOrName, ) { var markerOrRanges []MarkerOrRange for _, markerOrRangeOrName := range markerOrRangeOrNames { switch markerOrNameOrRange := markerOrRangeOrName.(type) { case string: marker, ok := f.testData.MarkerPositions[markerOrNameOrRange] if !ok { t.Fatalf("Marker '%s' not found", markerOrNameOrRange) } markerOrRanges = append(markerOrRanges, marker) case *Marker: markerOrRanges = append(markerOrRanges, markerOrNameOrRange) case *RangeMarker: markerOrRanges = append(markerOrRanges, markerOrNameOrRange) default: t.Fatalf("Invalid marker or range type: %T. Expected string, *Marker, or *RangeMarker.", markerOrNameOrRange) } } f.verifyBaselineDocumentHighlights(t, preferences, markerOrRanges) } func (f *FourslashTest) verifyBaselineDocumentHighlights( t *testing.T, preferences *ls.UserPreferences, markerOrRanges []MarkerOrRange, ) { for _, markerOrRange := range markerOrRanges { f.goToMarker(t, markerOrRange) params := &lsproto.DocumentHighlightParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentDocumentHighlightInfo, params) if resMsg == nil { if f.lastKnownMarkerName == nil { t.Fatalf("Nil response received for document highlights request at pos %v", f.currentCaretPosition) } else { t.Fatalf("Nil response received for document highlights request at marker '%s'", *f.lastKnownMarkerName) } } if !resultOk { if f.lastKnownMarkerName == nil { t.Fatalf("Unexpected document highlights response type at pos %v: %T", f.currentCaretPosition, resMsg.AsResponse().Result) } else { t.Fatalf("Unexpected document highlights response type at marker '%s': %T", *f.lastKnownMarkerName, resMsg.AsResponse().Result) } } highlights := result.DocumentHighlights if highlights == nil { highlights = &[]*lsproto.DocumentHighlight{} } var spans []lsproto.Location for _, h := range *highlights { spans = append(spans, lsproto.Location{ Uri: ls.FileNameToDocumentURI(f.activeFilename), Range: h.Range, }) } // Add result to baseline f.addResultToBaseline(t, "documentHighlights", f.getBaselineForLocationsWithFileContents(spans, baselineFourslashLocationsOptions{ marker: markerOrRange, markerName: "/*HIGHLIGHTS*/", })) } } // Collects all named markers if provided, or defaults to anonymous ranges func (f *FourslashTest) lookupMarkersOrGetRanges(t *testing.T, markers []string) []MarkerOrRange { var referenceLocations []MarkerOrRange if len(markers) == 0 { referenceLocations = core.Map(f.testData.Ranges, func(r *RangeMarker) MarkerOrRange { return r }) } else { referenceLocations = core.Map(markers, func(markerName string) MarkerOrRange { marker, ok := f.testData.MarkerPositions[markerName] if !ok { t.Fatalf("Marker '%s' not found", markerName) } return marker }) } return referenceLocations } func ptrTo[T any](v T) *T { return &v } // Insert text at the current caret position. func (f *FourslashTest) Insert(t *testing.T, text string) { f.typeText(t, text) } // Insert text and a new line at the current caret position. func (f *FourslashTest) InsertLine(t *testing.T, text string) { f.typeText(t, text+"\n") } // Removes the text at the current caret position as if the user pressed backspace `count` times. func (f *FourslashTest) Backspace(t *testing.T, count int) { script := f.getScriptInfo(f.activeFilename) offset := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition)) for range count { offset-- f.editScriptAndUpdateMarkers(t, f.activeFilename, offset, offset+1, "") f.currentCaretPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(offset)) // Don't need to examine formatting because there are no formatting changes on backspace. } // f.checkPostEditInvariants() // !!! do we need this? } // Enters text as if the user had pasted it. func (f *FourslashTest) Paste(t *testing.T, text string) { script := f.getScriptInfo(f.activeFilename) start := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition)) f.editScriptAndUpdateMarkers(t, f.activeFilename, start, start, text) // this.checkPostEditInvariants(); // !!! do we need this? } // Selects a line and replaces it with a new text. func (f *FourslashTest) ReplaceLine(t *testing.T, lineIndex int, text string) { f.selectLine(t, lineIndex) f.typeText(t, text) } func (f *FourslashTest) selectLine(t *testing.T, lineIndex int) { script := f.getScriptInfo(f.activeFilename) start := script.lineMap.LineStarts[lineIndex] end := script.lineMap.LineStarts[lineIndex+1] - 1 f.selectRange(t, core.NewTextRange(int(start), int(end))) } func (f *FourslashTest) selectRange(t *testing.T, textRange core.TextRange) { script := f.getScriptInfo(f.activeFilename) start := f.converters.PositionToLineAndCharacter(script, core.TextPos(textRange.Pos())) end := f.converters.PositionToLineAndCharacter(script, core.TextPos(textRange.End())) f.goToPosition(t, start) f.selectionEnd = &end } func (f *FourslashTest) getSelection() core.TextRange { script := f.getScriptInfo(f.activeFilename) if f.selectionEnd == nil { return core.NewTextRange( int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition)), int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition)), ) } return core.NewTextRange( int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition)), int(f.converters.LineAndCharacterToPosition(script, *f.selectionEnd)), ) } func (f *FourslashTest) Replace(t *testing.T, start int, length int, text string) { f.editScriptAndUpdateMarkers(t, f.activeFilename, start, start+length, text) // f.checkPostEditInvariants() // !!! do we need this? } // Inserts the text currently at the caret position character by character, as if the user typed it. func (f *FourslashTest) typeText(t *testing.T, text string) { script := f.getScriptInfo(f.activeFilename) offset := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition)) selection := f.getSelection() f.Replace(t, selection.Pos(), selection.End()-selection.Pos(), "") totalSize := 0 for totalSize < len(text) { r, size := utf8.DecodeRuneInString(text[totalSize:]) f.editScriptAndUpdateMarkers(t, f.activeFilename, totalSize+offset, totalSize+offset, string(r)) totalSize += size f.currentCaretPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(totalSize+offset)) // !!! formatting // Handle post-keystroke formatting // if this.enableFormatting { // const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, offset, ch, this.formatCodeSettings) // if edits.length { // offset += this.applyEdits(this.activeFile.fileName, edits) // } // } } // f.checkPostEditInvariants() // !!! do we need this? } // Edits the script and updates marker and range positions accordingly. // This does not update the current caret position. func (f *FourslashTest) editScriptAndUpdateMarkers(t *testing.T, fileName string, editStart int, editEnd int, newText string) { script := f.editScript(t, fileName, editStart, editEnd, newText) for _, marker := range f.testData.Markers { if marker.FileName() == fileName { marker.Position = updatePosition(marker.Position, editStart, editEnd, newText) marker.LSPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(marker.Position)) } } for _, rangeMarker := range f.testData.Ranges { if rangeMarker.FileName() == fileName { start := updatePosition(rangeMarker.Range.Pos(), editStart, editEnd, newText) end := updatePosition(rangeMarker.Range.End(), editStart, editEnd, newText) rangeMarker.Range = core.NewTextRange(start, end) rangeMarker.LSRange = f.converters.ToLSPRange(script, rangeMarker.Range) } } f.rangesByText = nil } func updatePosition(pos int, editStart int, editEnd int, newText string) int { if pos <= editStart { return pos } // If inside the edit, return -1 to mark as invalid if pos < editEnd { return -1 } return pos + len(newText) - (editEnd - editStart) } func (f *FourslashTest) editScript(t *testing.T, fileName string, start int, end int, newText string) *scriptInfo { script := f.getScriptInfo(fileName) changeRange := f.converters.ToLSPRange(script, core.NewTextRange(start, end)) if script == nil { panic(fmt.Sprintf("Script info for file %s not found", fileName)) } script.editContent(start, end, newText) err := f.vfs.WriteFile(fileName, script.content, false) if err != nil { panic(fmt.Sprintf("Failed to write file %s: %v", fileName, err)) } sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ TextDocument: lsproto.VersionedTextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(fileName), Version: script.version, }, ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{ { Partial: &lsproto.TextDocumentContentChangePartial{ Range: changeRange, Text: newText, }, }, }, }) return script } func (f *FourslashTest) getScriptInfo(fileName string) *scriptInfo { return f.scriptInfos[fileName] } // !!! expected tags func (f *FourslashTest) VerifyQuickInfoAt(t *testing.T, marker string, expectedText string, expectedDocumentation string) { f.GoToMarker(t, marker) hover := f.getQuickInfoAtCurrentPosition(t) f.verifyHoverContent(t, hover.Contents, expectedText, expectedDocumentation, f.getCurrentPositionPrefix()) } func (f *FourslashTest) getQuickInfoAtCurrentPosition(t *testing.T) *lsproto.Hover { params := &lsproto.HoverParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentHoverInfo, params) if resMsg == nil { t.Fatalf("Nil response received for hover request at marker '%s'", *f.lastKnownMarkerName) } if !resultOk { t.Fatalf("Unexpected hover response type at marker '%s': %T", *f.lastKnownMarkerName, resMsg.AsResponse().Result) } if result.Hover == nil { t.Fatalf("Expected hover result at marker '%s' but got nil", *f.lastKnownMarkerName) } return result.Hover } func (f *FourslashTest) verifyHoverContent( t *testing.T, actual lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings, expectedText string, expectedDocumentation string, prefix string, ) { switch { case actual.MarkupContent != nil: f.verifyHoverMarkdown(t, actual.MarkupContent.Value, expectedText, expectedDocumentation, prefix) default: t.Fatalf(prefix+"Expected markup content, got: %s", cmp.Diff(actual, nil)) } } func (f *FourslashTest) verifyHoverMarkdown( t *testing.T, actual string, expectedText string, expectedDocumentation string, prefix string, ) { expected := fmt.Sprintf("```tsx\n%s\n```\n%s", expectedText, expectedDocumentation) assertDeepEqual(t, actual, expected, prefix+"Hover markdown content mismatch") } func (f *FourslashTest) VerifyQuickInfoExists(t *testing.T) { if isEmpty, _ := f.quickInfoIsEmpty(t); isEmpty { t.Fatalf("Expected non-nil hover content at marker '%s'", *f.lastKnownMarkerName) } } func (f *FourslashTest) VerifyNotQuickInfoExists(t *testing.T) { if isEmpty, hover := f.quickInfoIsEmpty(t); !isEmpty { t.Fatalf("Expected empty hover content at marker '%s', got '%s'", *f.lastKnownMarkerName, cmp.Diff(hover, nil)) } } func (f *FourslashTest) quickInfoIsEmpty(t *testing.T) (bool, *lsproto.Hover) { hover := f.getQuickInfoAtCurrentPosition(t) if hover == nil || (hover.Contents.MarkupContent == nil && hover.Contents.MarkedStrings == nil && hover.Contents.String == nil) { return true, nil } return false, hover } func (f *FourslashTest) VerifyQuickInfoIs(t *testing.T, expectedText string, expectedDocumentation string) { hover := f.getQuickInfoAtCurrentPosition(t) f.verifyHoverContent(t, hover.Contents, expectedText, expectedDocumentation, f.getCurrentPositionPrefix()) } type SignatureHelpCase struct { Context *lsproto.SignatureHelpContext MarkerInput MarkerInput Expected *lsproto.SignatureHelp } func (f *FourslashTest) VerifySignatureHelp(t *testing.T, signatureHelpCases ...*SignatureHelpCase) { for _, option := range signatureHelpCases { switch marker := option.MarkerInput.(type) { case string: f.GoToMarker(t, marker) f.verifySignatureHelp(t, option.Context, option.Expected) case *Marker: f.goToMarker(t, marker) f.verifySignatureHelp(t, option.Context, option.Expected) case []string: for _, markerName := range marker { f.GoToMarker(t, markerName) f.verifySignatureHelp(t, option.Context, option.Expected) } case []*Marker: for _, marker := range marker { f.goToMarker(t, marker) f.verifySignatureHelp(t, option.Context, option.Expected) } case nil: f.verifySignatureHelp(t, option.Context, option.Expected) default: t.Fatalf("Invalid marker input type: %T. Expected string, *Marker, []string, or []*Marker.", option.MarkerInput) } } } func (f *FourslashTest) verifySignatureHelp( t *testing.T, context *lsproto.SignatureHelpContext, expected *lsproto.SignatureHelp, ) { prefix := f.getCurrentPositionPrefix() params := &lsproto.SignatureHelpParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, Context: context, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params) if resMsg == nil { t.Fatalf(prefix+"Nil response received for signature help request", f.lastKnownMarkerName) } if !resultOk { t.Fatalf(prefix+"Unexpected response type for signature help request: %T", resMsg.AsResponse().Result) } f.verifySignatureHelpResult(t, result.SignatureHelp, expected, prefix) } func (f *FourslashTest) verifySignatureHelpResult( t *testing.T, actual *lsproto.SignatureHelp, expected *lsproto.SignatureHelp, prefix string, ) { assertDeepEqual(t, actual, expected, prefix+" SignatureHelp mismatch") } func (f *FourslashTest) getCurrentPositionPrefix() string { if f.lastKnownMarkerName != nil { return fmt.Sprintf("At marker '%s': ", *f.lastKnownMarkerName) } return fmt.Sprintf("At position (Ln %d, Col %d): ", f.currentCaretPosition.Line, f.currentCaretPosition.Character) } func (f *FourslashTest) BaselineAutoImportsCompletions(t *testing.T, markerNames []string) { for _, markerName := range markerNames { f.GoToMarker(t, markerName) params := &lsproto.CompletionParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, Context: &lsproto.CompletionContext{}, } resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentCompletionInfo, params) prefix := fmt.Sprintf("At marker '%s': ", markerName) if resMsg == nil { t.Fatalf(prefix+"Nil response received for completion request for autoimports", f.lastKnownMarkerName) } if !resultOk { t.Fatalf(prefix+"Unexpected response type for completion request for autoimports: %T", resMsg.AsResponse().Result) } f.writeToBaseline("Auto Imports", "// === Auto Imports === \n") fileContent, ok := f.vfs.ReadFile(f.activeFilename) if !ok { t.Fatalf(prefix+"Failed to read file %s for auto-import baseline", f.activeFilename) } marker := f.testData.MarkerPositions[markerName] ext := strings.TrimPrefix(tspath.GetAnyExtensionFromPath(f.activeFilename, nil, true), ".") lang := core.IfElse(ext == "mts" || ext == "cts", "ts", ext) f.writeToBaseline("Auto Imports", (codeFence( lang, "// @FileName: "+f.activeFilename+"\n"+fileContent[:marker.Position]+"/*"+markerName+"*/"+fileContent[marker.Position:], ))) currentFile := newScriptInfo(f.activeFilename, fileContent) converters := ls.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *ls.LSPLineMap { return currentFile.lineMap }) var list []*lsproto.CompletionItem if result.Items == nil || len(*result.Items) == 0 { if result.List == nil || result.List.Items == nil || len(result.List.Items) == 0 { f.writeToBaseline("Auto Imports", "no autoimport completions found"+"\n\n") continue } list = result.List.Items } else { list = *result.Items } for _, item := range list { if item.Data == nil || *item.SortText != string(ls.SortTextAutoImportSuggestions) { continue } resMsg, details, resultOk := sendRequest(t, f, lsproto.CompletionItemResolveInfo, item) if resMsg == nil { t.Fatalf(prefix+"Nil response received for resolve completion", f.lastKnownMarkerName) } if !resultOk { t.Fatalf(prefix+"Unexpected response type for resolve completion: %T", resMsg.AsResponse().Result) } if details == nil || details.AdditionalTextEdits == nil || len(*details.AdditionalTextEdits) == 0 { t.Fatalf(prefix+"Entry %s from %s returned no code changes from completion details request", item.Label, item.Detail) } allChanges := *details.AdditionalTextEdits // !!! calculate the change provided by the completiontext // completionChange:= &lsproto.TextEdit{} // if details.TextEdit != nil { // completionChange = details.TextEdit.TextEdit // } else if details.AdditionalTextEdits != nil && len(*details.AdditionalTextEdits) > 0 { // completionChange = (*details.AdditionalTextEdits)[0] // } else { // completionChange.Range = lsproto.Range{ Start: marker.LSPosition, End: marker.LSPosition } // if item.InsertText != nil { // completionChange.NewText = *item.InsertText // } else { // completionChange.NewText = item.Label // } // } // allChanges := append(allChanges, completionChange) // sorted from back-of-file-most to front-of-file-most slices.SortFunc(allChanges, func(a, b *lsproto.TextEdit) int { return ls.ComparePositions(b.Range.Start, a.Range.Start) }) newFileContent := fileContent for _, change := range allChanges { newFileContent = newFileContent[:converters.LineAndCharacterToPosition(currentFile, change.Range.Start)] + change.NewText + newFileContent[converters.LineAndCharacterToPosition(currentFile, change.Range.End):] } f.writeToBaseline("Auto Imports", codeFence(lang, newFileContent)+"\n\n") } } } // string | *Marker | *RangeMarker type MarkerOrRangeOrName = any func (f *FourslashTest) VerifyBaselineRename( t *testing.T, preferences *ls.UserPreferences, markerOrNameOrRanges ...MarkerOrRangeOrName, ) { var markerOrRanges []MarkerOrRange for _, markerOrNameOrRange := range markerOrNameOrRanges { switch markerOrNameOrRange := markerOrNameOrRange.(type) { case string: marker, ok := f.testData.MarkerPositions[markerOrNameOrRange] if !ok { t.Fatalf("Marker '%s' not found", markerOrNameOrRange) } markerOrRanges = append(markerOrRanges, marker) case *Marker: markerOrRanges = append(markerOrRanges, markerOrNameOrRange) case *RangeMarker: markerOrRanges = append(markerOrRanges, markerOrNameOrRange) default: t.Fatalf("Invalid marker or range type: %T. Expected string, *Marker, or *RangeMarker.", markerOrNameOrRange) } } f.verifyBaselineRename(t, preferences, markerOrRanges) } func (f *FourslashTest) verifyBaselineRename( t *testing.T, preferences *ls.UserPreferences, markerOrRanges []MarkerOrRange, ) { for _, markerOrRange := range markerOrRanges { f.GoToMarkerOrRange(t, markerOrRange) // !!! set preferences params := &lsproto.RenameParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, NewName: "?", } prefix := f.getCurrentPositionPrefix() resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentRenameInfo, params) if resMsg == nil { t.Fatal(prefix + "Nil response received for rename request") } if !resultOk { t.Fatalf(prefix+"Unexpected rename response type: %T", resMsg.AsResponse().Result) } var changes map[lsproto.DocumentUri][]*lsproto.TextEdit if result.WorkspaceEdit != nil && result.WorkspaceEdit.Changes != nil { changes = *result.WorkspaceEdit.Changes } locationToText := map[lsproto.Location]string{} fileToRange := collections.MultiMap[lsproto.DocumentUri, lsproto.Range]{} for uri, edits := range changes { for _, edit := range edits { fileToRange.Add(uri, edit.Range) locationToText[lsproto.Location{Uri: uri, Range: edit.Range}] = edit.NewText } } var renameOptions strings.Builder if preferences != nil { if preferences.UseAliasesForRename != core.TSUnknown { fmt.Fprintf(&renameOptions, "// @useAliasesForRename: %v\n", preferences.UseAliasesForRename.IsTrue()) } if preferences.QuotePreference != ls.QuotePreferenceUnknown { fmt.Fprintf(&renameOptions, "// @quotePreference: %v\n", preferences.QuotePreference) } } baselineFileContent := f.getBaselineForGroupedLocationsWithFileContents( &fileToRange, baselineFourslashLocationsOptions{ marker: markerOrRange, markerName: "/*RENAME*/", endMarker: "RENAME|]", startMarkerPrefix: func(span lsproto.Location) *string { text := locationToText[span] prefixAndSuffix := strings.Split(text, "?") if prefixAndSuffix[0] != "" { return ptrTo("/*START PREFIX*/" + prefixAndSuffix[0]) } return nil }, endMarkerSuffix: func(span lsproto.Location) *string { text := locationToText[span] prefixAndSuffix := strings.Split(text, "?") if prefixAndSuffix[1] != "" { return ptrTo(prefixAndSuffix[1] + "/*END SUFFIX*/") } return nil }, }, ) var baselineResult string if renameOptions.Len() > 0 { baselineResult = renameOptions.String() + "\n" + baselineFileContent } else { baselineResult = baselineFileContent } f.addResultToBaseline(t, "findRenameLocations", baselineResult, ) } } func (f *FourslashTest) VerifyRenameSucceeded(t *testing.T, preferences *ls.UserPreferences) { // !!! set preferences params := &lsproto.RenameParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, NewName: "?", } prefix := f.getCurrentPositionPrefix() resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentRenameInfo, params) if resMsg == nil { t.Fatal(prefix + "Nil response received for rename request") } if !resultOk { t.Fatalf(prefix+"Unexpected rename response type: %T", resMsg.AsResponse().Result) } if result.WorkspaceEdit == nil || result.WorkspaceEdit.Changes == nil || len(*result.WorkspaceEdit.Changes) == 0 { t.Fatal(prefix + "Expected rename to succeed, but got no changes") } } func (f *FourslashTest) VerifyRenameFailed(t *testing.T, preferences *ls.UserPreferences) { // !!! set preferences params := &lsproto.RenameParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: ls.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, NewName: "?", } prefix := f.getCurrentPositionPrefix() resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentRenameInfo, params) if resMsg == nil { t.Fatal(prefix + "Nil response received for rename request") } if !resultOk { t.Fatalf(prefix+"Unexpected rename response type: %T", resMsg.AsResponse().Result) } if result.WorkspaceEdit != nil { t.Fatalf(prefix+"Expected rename to fail, but got changes: %s", cmp.Diff(result.WorkspaceEdit, nil)) } } func (f *FourslashTest) VerifyBaselineRenameAtRangesWithText( t *testing.T, preferences *ls.UserPreferences, texts ...string, ) { var markerOrRanges []MarkerOrRange for _, text := range texts { ranges := core.Map(f.GetRangesByText().Get(text), func(r *RangeMarker) MarkerOrRange { return r }) markerOrRanges = append(markerOrRanges, ranges...) } f.verifyBaselineRename(t, preferences, markerOrRanges) } func (f *FourslashTest) GetRangesByText() *collections.MultiMap[string, *RangeMarker] { if f.rangesByText != nil { return f.rangesByText } rangesByText := collections.MultiMap[string, *RangeMarker]{} for _, r := range f.testData.Ranges { rangeText := f.getRangeText(r) rangesByText.Add(rangeText, r) } f.rangesByText = &rangesByText return &rangesByText } func (f *FourslashTest) getRangeText(r *RangeMarker) string { script := f.getScriptInfo(r.FileName()) return script.content[r.Range.Pos():r.Range.End()] } func (f *FourslashTest) verifyBaselines(t *testing.T) { for command, content := range f.baselines { baseline.Run(t, getBaselineFileName(t, command), content.String(), getBaselineOptions(command)) } }