2025-10-15 10:12:44 +03:00

1745 lines
57 KiB
Go

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))
}
}