5816 lines
198 KiB
Go
5816 lines
198 KiB
Go
package fourslash
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/microsoft/typescript-go/internal/bundled"
|
|
"github.com/microsoft/typescript-go/internal/collections"
|
|
"github.com/microsoft/typescript-go/internal/core"
|
|
"github.com/microsoft/typescript-go/internal/diagnostics"
|
|
"github.com/microsoft/typescript-go/internal/diagnosticwriter"
|
|
"github.com/microsoft/typescript-go/internal/execute/tsctests"
|
|
"github.com/microsoft/typescript-go/internal/json"
|
|
"github.com/microsoft/typescript-go/internal/jsonrpc"
|
|
"github.com/microsoft/typescript-go/internal/locale"
|
|
"github.com/microsoft/typescript-go/internal/ls"
|
|
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
|
"github.com/microsoft/typescript-go/internal/ls/lsutil"
|
|
"github.com/microsoft/typescript-go/internal/lsp"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"github.com/microsoft/typescript-go/internal/project"
|
|
"github.com/microsoft/typescript-go/internal/repo"
|
|
"github.com/microsoft/typescript-go/internal/stringutil"
|
|
"github.com/microsoft/typescript-go/internal/testutil/baseline"
|
|
"github.com/microsoft/typescript-go/internal/testutil/harnessutil"
|
|
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
|
"github.com/microsoft/typescript-go/internal/testutil/tsbaseline"
|
|
"github.com/microsoft/typescript-go/internal/tspath"
|
|
"github.com/microsoft/typescript-go/internal/vfs"
|
|
"github.com/microsoft/typescript-go/internal/vfs/iovfs"
|
|
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
|
"gotest.tools/v3/assert"
|
|
)
|
|
|
|
type FourslashTest struct {
|
|
client *lsptestutil.LSPClient
|
|
vfs vfs.FS
|
|
|
|
testData *TestData // !!! consolidate test files from test data and script info
|
|
baselines map[baselineCommand]*strings.Builder
|
|
rangesByText *collections.MultiMap[string, *RangeMarker]
|
|
openFiles map[string]struct{}
|
|
stateBaseline *stateBaseline
|
|
|
|
scriptInfos map[string]*scriptInfo
|
|
converters *lsconv.Converters
|
|
|
|
stateEnableFormatting bool
|
|
reportFormatOnTypeCrash bool
|
|
userPreferences lsutil.UserPreferences
|
|
currentCaretPosition lsproto.Position
|
|
lastKnownMarkerName *string
|
|
activeFilename string
|
|
selectionEnd *lsproto.Position
|
|
|
|
capabilities *lsproto.ClientCapabilities
|
|
isStradaServer bool // Whether this is a fourslash server test in Strada. !!! Remove once we don't need to diff baselines.
|
|
|
|
// Semantic token configuration
|
|
semanticTokenTypes []string
|
|
semanticTokenModifiers []string
|
|
}
|
|
|
|
type scriptInfo struct {
|
|
fileName string
|
|
content string
|
|
lineMap *lsconv.LSPLineMap
|
|
version int32
|
|
}
|
|
|
|
type textEditSpan struct {
|
|
start int
|
|
end int
|
|
length int
|
|
}
|
|
|
|
func newScriptInfo(fileName string, content string) *scriptInfo {
|
|
return &scriptInfo{
|
|
fileName: fileName,
|
|
content: content,
|
|
lineMap: lsconv.ComputeLSPLineStarts(content),
|
|
version: 1,
|
|
}
|
|
}
|
|
|
|
func (s *scriptInfo) editContent(change core.TextChange) {
|
|
s.content = change.ApplyTo(s.content)
|
|
s.lineMap = lsconv.ComputeLSPLineStarts(s.content)
|
|
s.version++
|
|
}
|
|
|
|
func (s *scriptInfo) Text() string {
|
|
return s.content
|
|
}
|
|
|
|
func (s *scriptInfo) FileName() string {
|
|
return s.fileName
|
|
}
|
|
|
|
func (s *scriptInfo) GetLineContent(line int) string {
|
|
numLines := len(s.lineMap.LineStarts)
|
|
if line < 0 || line >= numLines {
|
|
return ""
|
|
}
|
|
start := s.lineMap.LineStarts[line]
|
|
var end core.TextPos
|
|
if line+1 < numLines {
|
|
end = s.lineMap.LineStarts[line+1]
|
|
} else {
|
|
end = core.TextPos(len(s.content))
|
|
}
|
|
|
|
return strings.TrimRight(s.content[start:end], "\r\n")
|
|
}
|
|
|
|
const rootDir = "/"
|
|
|
|
var parseCache = project.NewParseCache(
|
|
project.RefCountCacheOptions{
|
|
DisableDeletion: true,
|
|
},
|
|
)
|
|
|
|
func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, content string) (*FourslashTest, func()) {
|
|
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]any)
|
|
scriptInfos := make(map[string]*scriptInfo)
|
|
testData := ParseTestData(t, content, fileName)
|
|
for _, file := range testData.Files {
|
|
filePath := tspath.GetNormalizedAbsolutePath(file.fileName, rootDir)
|
|
// Dynamic files (e.g., untitled:) shouldn't be added to the VFS
|
|
if !tspath.IsDynamicFileName(filePath) {
|
|
testfs[filePath] = file.Content
|
|
}
|
|
scriptInfos[filePath] = newScriptInfo(filePath, file.Content)
|
|
}
|
|
|
|
for link, target := range testData.Symlinks {
|
|
filePath := tspath.GetNormalizedAbsolutePath(link, rootDir)
|
|
testfs[filePath] = vfstest.Symlink(tspath.GetNormalizedAbsolutePath(target, rootDir))
|
|
}
|
|
|
|
// !!! use default compiler options for inferred project as base
|
|
compilerOptions := &core.CompilerOptions{
|
|
SkipDefaultLibCheck: core.TSTrue,
|
|
Target: core.ScriptTargetLatestStandard,
|
|
Jsx: core.JsxEmitPreserve,
|
|
}
|
|
harnessOptions := harnessutil.HarnessOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: rootDir}
|
|
harnessutil.SetOptionsFromTestConfig(t, testData.GlobalOptions, compilerOptions, &harnessOptions, rootDir, true /*allowUnknownOptions*/)
|
|
if commandLines := testData.GlobalOptions["tsc"]; commandLines != "" {
|
|
for commandLine := range strings.SplitSeq(commandLines, ",") {
|
|
tsctests.GetFileMapWithBuild(testfs, strings.Split(commandLine, " "))
|
|
}
|
|
}
|
|
|
|
harnessutil.SkipUnsupportedCompilerOptions(t, compilerOptions)
|
|
|
|
fsFromMap := vfstest.FromMap(testfs, harnessOptions.UseCaseSensitiveFileNames)
|
|
fs := bundled.WrapFS(fsFromMap)
|
|
|
|
serverOpts := lsp.ServerOptions{
|
|
Err: io.Discard,
|
|
|
|
Cwd: "/",
|
|
FS: fs,
|
|
DefaultLibraryPath: bundled.LibPath(),
|
|
|
|
ParseCache: parseCache,
|
|
}
|
|
|
|
converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(fileName string) *lsconv.LSPLineMap {
|
|
scriptInfo, ok := scriptInfos[fileName]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return scriptInfo.lineMap
|
|
})
|
|
|
|
f := &FourslashTest{
|
|
testData: &testData,
|
|
stateEnableFormatting: true,
|
|
reportFormatOnTypeCrash: true,
|
|
userPreferences: lsutil.NewDefaultUserPreferences(),
|
|
vfs: fs,
|
|
scriptInfos: scriptInfos,
|
|
converters: converters,
|
|
baselines: make(map[baselineCommand]*strings.Builder),
|
|
openFiles: make(map[string]struct{}),
|
|
semanticTokenTypes: defaultSemanticTokenTypes(),
|
|
semanticTokenModifiers: defaultSemanticTokenModifiers(),
|
|
}
|
|
client, closeClient := lsptestutil.NewLSPClient(t, serverOpts, f.handleServerRequest)
|
|
f.client = client
|
|
|
|
// !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support
|
|
// !!! replace with a proper request *after initialize*
|
|
client.SetCompilerOptionsForInferredProjects(compilerOptions)
|
|
f.initialize(t, capabilities)
|
|
|
|
if testData.isStateBaseliningEnabled() {
|
|
// Single baseline, so initialize project state baseline too
|
|
f.stateBaseline = newStateBaseline(fsFromMap.(iovfs.FsWithSys))
|
|
} else {
|
|
for _, file := range testData.Files {
|
|
f.openFile(t, file.fileName)
|
|
}
|
|
f.activeFilename = f.testData.Files[0].fileName
|
|
}
|
|
|
|
_, testPath, _, _ := runtime.Caller(1)
|
|
return f, func() {
|
|
t.Helper()
|
|
err := closeClient()
|
|
if err != nil {
|
|
t.Errorf("goroutine error: %v", err)
|
|
}
|
|
f.verifyBaselines(t, testPath)
|
|
}
|
|
}
|
|
|
|
// handleServerRequest handles requests initiated by the server (e.g., workspace/configuration).
|
|
func (f *FourslashTest) handleServerRequest(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
|
|
switch req.Method {
|
|
case lsproto.MethodWorkspaceConfiguration:
|
|
// Return current user preferences for each requested section.
|
|
// The server requests multiple sections (js/ts, typescript, javascript, editor);
|
|
// we return user preferences for "js/ts" and nil for others.
|
|
params, err := lsproto.UnmarshalParams[*lsproto.ConfigurationParams](req)
|
|
if err != nil || params == nil || params.Items == nil {
|
|
return &lsproto.ResponseMessage{
|
|
ID: req.ID,
|
|
JSONRPC: req.JSONRPC,
|
|
Result: []any{f.userPreferences},
|
|
}
|
|
}
|
|
results := make([]any, len(params.Items))
|
|
for i, item := range params.Items {
|
|
if item.Section != nil && *item.Section == "js/ts" {
|
|
results[i] = f.userPreferences
|
|
}
|
|
}
|
|
return &lsproto.ResponseMessage{
|
|
ID: req.ID,
|
|
JSONRPC: req.JSONRPC,
|
|
Result: results,
|
|
}
|
|
|
|
case lsproto.MethodClientRegisterCapability:
|
|
// Accept all capability registrations
|
|
return &lsproto.ResponseMessage{
|
|
ID: req.ID,
|
|
JSONRPC: req.JSONRPC,
|
|
Result: lsproto.Null{},
|
|
}
|
|
|
|
case lsproto.MethodClientUnregisterCapability:
|
|
// Accept all capability unregistrations
|
|
return &lsproto.ResponseMessage{
|
|
ID: req.ID,
|
|
JSONRPC: req.JSONRPC,
|
|
Result: lsproto.Null{},
|
|
}
|
|
|
|
default:
|
|
// Unknown server request
|
|
return &lsproto.ResponseMessage{
|
|
ID: req.ID,
|
|
JSONRPC: req.JSONRPC,
|
|
Error: &jsonrpc.ResponseError{
|
|
Code: int32(lsproto.ErrorCodeMethodNotFound),
|
|
Message: fmt.Sprintf("Unknown method: %s", req.Method),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
func getBaseFileNameFromTest(t *testing.T) string {
|
|
name := t.Name()
|
|
name = core.LastOrNil(strings.Split(name, "/"))
|
|
name = strings.TrimPrefix(name, "Test")
|
|
name = stringutil.LowerFirstChar(name)
|
|
|
|
// Special case: TypeScript has "callHierarchyFunctionAmbiguity.N" with periods
|
|
switch name {
|
|
case "callHierarchyFunctionAmbiguity1":
|
|
name = "callHierarchyFunctionAmbiguity.1"
|
|
case "callHierarchyFunctionAmbiguity2":
|
|
name = "callHierarchyFunctionAmbiguity.2"
|
|
case "callHierarchyFunctionAmbiguity3":
|
|
name = "callHierarchyFunctionAmbiguity.3"
|
|
case "callHierarchyFunctionAmbiguity4":
|
|
name = "callHierarchyFunctionAmbiguity.4"
|
|
case "callHierarchyFunctionAmbiguity5":
|
|
name = "callHierarchyFunctionAmbiguity.5"
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
const showCodeLensLocationsCommandName = "typescript.showCodeLensLocations"
|
|
|
|
func (f *FourslashTest) initialize(t *testing.T, capabilities *lsproto.ClientCapabilities) {
|
|
params := &lsproto.InitializeParams{
|
|
Locale: new("en-US"),
|
|
InitializationOptions: &lsproto.InitializationOptionsOrNull{
|
|
InitializationOptions: &lsproto.InitializationOptions{
|
|
CodeLensShowLocationsCommandName: new(showCodeLensLocationsCommandName),
|
|
},
|
|
},
|
|
}
|
|
params.Capabilities = getCapabilitiesWithDefaults(capabilities)
|
|
f.capabilities = params.Capabilities
|
|
resp, _, ok := lsptestutil.SendRequest(t, f.client, lsproto.InitializeInfo, params)
|
|
if !ok {
|
|
t.Fatalf("Initialize request failed")
|
|
}
|
|
if resp.AsResponse().Error != nil {
|
|
t.Fatalf("Initialize request returned error: %s", resp.AsResponse().Error.String())
|
|
}
|
|
lsptestutil.SendNotification(t, f.client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
|
|
|
|
// Wait for the initial configuration exchange to complete
|
|
// The server will send workspace/configuration as part of handleInitialized
|
|
<-f.client.Server.InitComplete()
|
|
}
|
|
|
|
func defaultSemanticTokenTypes() []string {
|
|
return []string{
|
|
string(lsproto.SemanticTokenTypeNamespace),
|
|
string(lsproto.SemanticTokenTypeClass),
|
|
string(lsproto.SemanticTokenTypeEnum),
|
|
string(lsproto.SemanticTokenTypeInterface),
|
|
string(lsproto.SemanticTokenTypeStruct),
|
|
string(lsproto.SemanticTokenTypeTypeParameter),
|
|
string(lsproto.SemanticTokenTypeType),
|
|
string(lsproto.SemanticTokenTypeParameter),
|
|
string(lsproto.SemanticTokenTypeVariable),
|
|
string(lsproto.SemanticTokenTypeProperty),
|
|
string(lsproto.SemanticTokenTypeEnumMember),
|
|
string(lsproto.SemanticTokenTypeDecorator),
|
|
string(lsproto.SemanticTokenTypeEvent),
|
|
string(lsproto.SemanticTokenTypeFunction),
|
|
string(lsproto.SemanticTokenTypeMethod),
|
|
string(lsproto.SemanticTokenTypeMacro),
|
|
string(lsproto.SemanticTokenTypeLabel),
|
|
string(lsproto.SemanticTokenTypeComment),
|
|
string(lsproto.SemanticTokenTypeString),
|
|
string(lsproto.SemanticTokenTypeKeyword),
|
|
string(lsproto.SemanticTokenTypeNumber),
|
|
string(lsproto.SemanticTokenTypeRegexp),
|
|
string(lsproto.SemanticTokenTypeOperator),
|
|
}
|
|
}
|
|
|
|
func defaultSemanticTokenModifiers() []string {
|
|
return []string{
|
|
string(lsproto.SemanticTokenModifierDeclaration),
|
|
string(lsproto.SemanticTokenModifierDefinition),
|
|
string(lsproto.SemanticTokenModifierReadonly),
|
|
string(lsproto.SemanticTokenModifierStatic),
|
|
string(lsproto.SemanticTokenModifierDeprecated),
|
|
string(lsproto.SemanticTokenModifierAbstract),
|
|
string(lsproto.SemanticTokenModifierAsync),
|
|
string(lsproto.SemanticTokenModifierModification),
|
|
string(lsproto.SemanticTokenModifierDocumentation),
|
|
string(lsproto.SemanticTokenModifierDefaultLibrary),
|
|
"local",
|
|
}
|
|
}
|
|
|
|
// If modifying the defaults, update GetDefaultCapabilities too.
|
|
var (
|
|
ptrTrue = new(true)
|
|
defaultCompletionCapabilities = &lsproto.CompletionClientCapabilities{
|
|
CompletionItem: &lsproto.ClientCompletionItemOptions{
|
|
SnippetSupport: ptrTrue,
|
|
CommitCharactersSupport: ptrTrue,
|
|
PreselectSupport: ptrTrue,
|
|
LabelDetailsSupport: ptrTrue,
|
|
InsertReplaceSupport: ptrTrue,
|
|
DocumentationFormat: &[]lsproto.MarkupKind{lsproto.MarkupKindMarkdown, lsproto.MarkupKindPlainText},
|
|
},
|
|
CompletionList: &lsproto.CompletionListCapabilities{
|
|
ItemDefaults: &[]string{"commitCharacters", "editRange"},
|
|
},
|
|
}
|
|
defaultDefinitionCapabilities = &lsproto.DefinitionClientCapabilities{
|
|
LinkSupport: ptrTrue,
|
|
}
|
|
defaultTypeDefinitionCapabilities = &lsproto.TypeDefinitionClientCapabilities{
|
|
LinkSupport: ptrTrue,
|
|
}
|
|
defaultImplementationCapabilities = &lsproto.ImplementationClientCapabilities{
|
|
LinkSupport: ptrTrue,
|
|
}
|
|
defaultHoverCapabilities = &lsproto.HoverClientCapabilities{
|
|
ContentFormat: &[]lsproto.MarkupKind{lsproto.MarkupKindMarkdown, lsproto.MarkupKindPlainText},
|
|
}
|
|
defaultExperimentalCapabilities = &lsproto.ExperimentalClientCapabilities{
|
|
HoverVerbosityLevel: ptrTrue,
|
|
}
|
|
defaultSignatureHelpCapabilities = &lsproto.SignatureHelpClientCapabilities{
|
|
SignatureInformation: &lsproto.ClientSignatureInformationOptions{
|
|
DocumentationFormat: &[]lsproto.MarkupKind{lsproto.MarkupKindMarkdown, lsproto.MarkupKindPlainText},
|
|
ParameterInformation: &lsproto.ClientSignatureParameterInformationOptions{
|
|
LabelOffsetSupport: ptrTrue,
|
|
},
|
|
ActiveParameterSupport: ptrTrue,
|
|
},
|
|
ContextSupport: ptrTrue,
|
|
}
|
|
defaultDocumentSymbolCapabilities = &lsproto.DocumentSymbolClientCapabilities{
|
|
HierarchicalDocumentSymbolSupport: ptrTrue,
|
|
}
|
|
defaultFoldingRangeCapabilities = &lsproto.FoldingRangeClientCapabilities{
|
|
RangeLimit: new(uint32(5000)),
|
|
// LineFoldingOnly: ptrTrue,
|
|
FoldingRangeKind: &lsproto.ClientFoldingRangeKindOptions{
|
|
ValueSet: &[]lsproto.FoldingRangeKind{
|
|
lsproto.FoldingRangeKindComment,
|
|
lsproto.FoldingRangeKindImports,
|
|
lsproto.FoldingRangeKindRegion,
|
|
},
|
|
},
|
|
FoldingRange: &lsproto.ClientFoldingRangeOptions{
|
|
CollapsedText: ptrTrue, // Unused by our testing, but set to exercise the code.
|
|
},
|
|
}
|
|
defaultDiagnosticCapabilities = &lsproto.DiagnosticClientCapabilities{
|
|
RelatedInformation: ptrTrue,
|
|
TagSupport: &lsproto.ClientDiagnosticsTagOptions{
|
|
ValueSet: []lsproto.DiagnosticTag{
|
|
lsproto.DiagnosticTagUnnecessary,
|
|
lsproto.DiagnosticTagDeprecated,
|
|
},
|
|
},
|
|
}
|
|
defaultPublishDiagnosticCapabilities = &lsproto.PublishDiagnosticsClientCapabilities{
|
|
RelatedInformation: ptrTrue,
|
|
TagSupport: &lsproto.ClientDiagnosticsTagOptions{
|
|
ValueSet: []lsproto.DiagnosticTag{
|
|
lsproto.DiagnosticTagUnnecessary,
|
|
lsproto.DiagnosticTagDeprecated,
|
|
},
|
|
},
|
|
}
|
|
defaultWorkspaceEditCapabilities = &lsproto.WorkspaceEditClientCapabilities{
|
|
DocumentChanges: ptrTrue,
|
|
ResourceOperations: &[]lsproto.ResourceOperationKind{
|
|
lsproto.ResourceOperationKindRename,
|
|
},
|
|
}
|
|
)
|
|
|
|
func GetDefaultCapabilities() *lsproto.ClientCapabilities {
|
|
return &lsproto.ClientCapabilities{
|
|
General: &lsproto.GeneralClientCapabilities{
|
|
PositionEncodings: &[]lsproto.PositionEncodingKind{lsproto.PositionEncodingKindUTF8},
|
|
},
|
|
Experimental: &lsproto.ExperimentalClientCapabilities{
|
|
HoverVerbosityLevel: ptrTrue,
|
|
},
|
|
TextDocument: &lsproto.TextDocumentClientCapabilities{
|
|
Completion: &lsproto.CompletionClientCapabilities{
|
|
CompletionItem: &lsproto.ClientCompletionItemOptions{
|
|
SnippetSupport: ptrTrue,
|
|
CommitCharactersSupport: ptrTrue,
|
|
PreselectSupport: ptrTrue,
|
|
LabelDetailsSupport: ptrTrue,
|
|
InsertReplaceSupport: ptrTrue,
|
|
DocumentationFormat: &[]lsproto.MarkupKind{lsproto.MarkupKindMarkdown, lsproto.MarkupKindPlainText},
|
|
},
|
|
CompletionList: &lsproto.CompletionListCapabilities{
|
|
ItemDefaults: &[]string{"commitCharacters", "editRange"},
|
|
},
|
|
},
|
|
Diagnostic: &lsproto.DiagnosticClientCapabilities{
|
|
RelatedInformation: ptrTrue,
|
|
TagSupport: &lsproto.ClientDiagnosticsTagOptions{
|
|
ValueSet: []lsproto.DiagnosticTag{
|
|
lsproto.DiagnosticTagUnnecessary,
|
|
lsproto.DiagnosticTagDeprecated,
|
|
},
|
|
},
|
|
},
|
|
PublishDiagnostics: &lsproto.PublishDiagnosticsClientCapabilities{
|
|
RelatedInformation: ptrTrue,
|
|
TagSupport: &lsproto.ClientDiagnosticsTagOptions{
|
|
ValueSet: []lsproto.DiagnosticTag{
|
|
lsproto.DiagnosticTagUnnecessary,
|
|
lsproto.DiagnosticTagDeprecated,
|
|
},
|
|
},
|
|
},
|
|
Definition: &lsproto.DefinitionClientCapabilities{
|
|
LinkSupport: ptrTrue,
|
|
},
|
|
TypeDefinition: &lsproto.TypeDefinitionClientCapabilities{
|
|
LinkSupport: ptrTrue,
|
|
},
|
|
Implementation: &lsproto.ImplementationClientCapabilities{
|
|
LinkSupport: ptrTrue,
|
|
},
|
|
Hover: &lsproto.HoverClientCapabilities{
|
|
ContentFormat: &[]lsproto.MarkupKind{lsproto.MarkupKindMarkdown, lsproto.MarkupKindPlainText},
|
|
},
|
|
SignatureHelp: &lsproto.SignatureHelpClientCapabilities{
|
|
SignatureInformation: &lsproto.ClientSignatureInformationOptions{
|
|
DocumentationFormat: &[]lsproto.MarkupKind{lsproto.MarkupKindMarkdown, lsproto.MarkupKindPlainText},
|
|
ParameterInformation: &lsproto.ClientSignatureParameterInformationOptions{
|
|
LabelOffsetSupport: ptrTrue,
|
|
},
|
|
ActiveParameterSupport: ptrTrue,
|
|
},
|
|
ContextSupport: ptrTrue,
|
|
},
|
|
DocumentSymbol: &lsproto.DocumentSymbolClientCapabilities{
|
|
HierarchicalDocumentSymbolSupport: ptrTrue,
|
|
},
|
|
FoldingRange: &lsproto.FoldingRangeClientCapabilities{
|
|
RangeLimit: new(uint32(5000)),
|
|
FoldingRangeKind: &lsproto.ClientFoldingRangeKindOptions{
|
|
ValueSet: &[]lsproto.FoldingRangeKind{
|
|
lsproto.FoldingRangeKindComment,
|
|
lsproto.FoldingRangeKindImports,
|
|
lsproto.FoldingRangeKindRegion,
|
|
},
|
|
},
|
|
FoldingRange: &lsproto.ClientFoldingRangeOptions{
|
|
CollapsedText: ptrTrue,
|
|
},
|
|
},
|
|
},
|
|
Workspace: &lsproto.WorkspaceClientCapabilities{
|
|
Configuration: ptrTrue,
|
|
FileOperations: &lsproto.FileOperationClientCapabilities{
|
|
WillRename: ptrTrue,
|
|
},
|
|
WorkspaceEdit: &lsproto.WorkspaceEditClientCapabilities{
|
|
DocumentChanges: ptrTrue,
|
|
ResourceOperations: &[]lsproto.ResourceOperationKind{
|
|
lsproto.ResourceOperationKindRename,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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.Experimental == nil {
|
|
capabilitiesWithDefaults.Experimental = defaultExperimentalCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument == nil {
|
|
capabilitiesWithDefaults.TextDocument = &lsproto.TextDocumentClientCapabilities{}
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.Completion == nil {
|
|
capabilitiesWithDefaults.TextDocument.Completion = defaultCompletionCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.Diagnostic == nil {
|
|
capabilitiesWithDefaults.TextDocument.Diagnostic = defaultDiagnosticCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.PublishDiagnostics == nil {
|
|
capabilitiesWithDefaults.TextDocument.PublishDiagnostics = defaultPublishDiagnosticCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.SemanticTokens == nil {
|
|
capabilitiesWithDefaults.TextDocument.SemanticTokens = &lsproto.SemanticTokensClientCapabilities{
|
|
Requests: &lsproto.ClientSemanticTokensRequestOptions{
|
|
Full: &lsproto.BooleanOrClientSemanticTokensRequestFullDelta{
|
|
Boolean: ptrTrue,
|
|
},
|
|
},
|
|
TokenTypes: defaultSemanticTokenTypes(),
|
|
TokenModifiers: defaultSemanticTokenModifiers(),
|
|
Formats: []lsproto.TokenFormat{lsproto.TokenFormatRelative},
|
|
}
|
|
}
|
|
if capabilitiesWithDefaults.Workspace == nil {
|
|
capabilitiesWithDefaults.Workspace = &lsproto.WorkspaceClientCapabilities{}
|
|
}
|
|
if capabilitiesWithDefaults.Workspace.FileOperations == nil {
|
|
capabilitiesWithDefaults.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{
|
|
WillRename: ptrTrue,
|
|
}
|
|
}
|
|
if capabilitiesWithDefaults.Workspace.WorkspaceEdit == nil {
|
|
capabilitiesWithDefaults.Workspace.WorkspaceEdit = defaultWorkspaceEditCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.Workspace.Configuration == nil {
|
|
capabilitiesWithDefaults.Workspace.Configuration = ptrTrue
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.Definition == nil {
|
|
capabilitiesWithDefaults.TextDocument.Definition = defaultDefinitionCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.TypeDefinition == nil {
|
|
capabilitiesWithDefaults.TextDocument.TypeDefinition = defaultTypeDefinitionCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.Implementation == nil {
|
|
capabilitiesWithDefaults.TextDocument.Implementation = defaultImplementationCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.Hover == nil {
|
|
capabilitiesWithDefaults.TextDocument.Hover = defaultHoverCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.SignatureHelp == nil {
|
|
capabilitiesWithDefaults.TextDocument.SignatureHelp = defaultSignatureHelpCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.DocumentSymbol == nil {
|
|
capabilitiesWithDefaults.TextDocument.DocumentSymbol = defaultDocumentSymbolCapabilities
|
|
}
|
|
if capabilitiesWithDefaults.TextDocument.FoldingRange == nil {
|
|
capabilitiesWithDefaults.TextDocument.FoldingRange = defaultFoldingRangeCapabilities
|
|
}
|
|
return &capabilitiesWithDefaults
|
|
}
|
|
|
|
func sendRequest[Params, Resp any](t *testing.T, f *FourslashTest, info lsproto.RequestInfo[Params, Resp], params Params) Resp {
|
|
t.Helper()
|
|
return sendRequestAndBaselineWorker(t, f, info, params, true)
|
|
}
|
|
|
|
func sendRequestAndBaselineWorker[Params, Resp any](t *testing.T, f *FourslashTest, info lsproto.RequestInfo[Params, Resp], params Params, baselineProjects bool) Resp {
|
|
t.Helper()
|
|
prefix := f.getCurrentPositionPrefix()
|
|
if baselineProjects {
|
|
f.baselineState(t)
|
|
}
|
|
f.baselineRequestOrNotification(t, info.Method, params)
|
|
resMsg, result, resultOk := lsptestutil.SendRequest(t, f.client, info, params)
|
|
if baselineProjects {
|
|
f.baselineState(t)
|
|
}
|
|
switch info.Method {
|
|
case lsproto.MethodTextDocumentOnTypeFormatting:
|
|
if !f.reportFormatOnTypeCrash {
|
|
break
|
|
}
|
|
fallthrough
|
|
default:
|
|
if resMsg == nil {
|
|
t.Fatalf(prefix+"Nil response received for %s request", info.Method)
|
|
}
|
|
resp := resMsg.AsResponse()
|
|
if resp.Error != nil {
|
|
t.Fatalf(prefix+"%s request returned error: %s", info.Method, resp.Error.String())
|
|
}
|
|
if !resultOk {
|
|
t.Fatalf(prefix+"Unexpected %s response type: %T, error: %v", info.Method, resp.Result, resp.Error)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sendNotification[Params any](t *testing.T, f *FourslashTest, info lsproto.NotificationInfo[Params], params Params) {
|
|
t.Helper()
|
|
if info.Method != lsproto.MethodTextDocumentDidChange {
|
|
// This is called eg when doing typeText = which is series of edits and formatting - which becomes non deterministic "after state"
|
|
// The notification can only guarantee before state and thats what it baselines, but in case of type it creates
|
|
// multiple edits which results in getting different state -based on if the snapshot was updated or not at the time of formatting requests
|
|
// So this is used for all the incremental edits - to baseline only request data but not project state between those edits
|
|
f.baselineState(t)
|
|
f.updateState(info.Method, params)
|
|
}
|
|
f.baselineRequestOrNotification(t, info.Method, params)
|
|
lsptestutil.SendNotification(t, f.client, info, params)
|
|
}
|
|
|
|
func (f *FourslashTest) updateState(method lsproto.Method, params any) {
|
|
switch method {
|
|
case lsproto.MethodTextDocumentDidOpen:
|
|
f.openFiles[params.(*lsproto.DidOpenTextDocumentParams).TextDocument.Uri.FileName()] = struct{}{}
|
|
case lsproto.MethodTextDocumentDidClose:
|
|
delete(f.openFiles, params.(*lsproto.DidCloseTextDocumentParams).TextDocument.Uri.FileName())
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) GetOptions() lsutil.UserPreferences {
|
|
return f.userPreferences
|
|
}
|
|
|
|
func (f *FourslashTest) Configure(t *testing.T, config lsutil.UserPreferences) {
|
|
// We send 'js/ts' by default because that is what we expect the primary config to be in vscode and VS (one
|
|
// set of preferences for both languages). This should be fine in fourslash since tests that need
|
|
// multiple options usually send reconfiguration commands for each `verify` anyways
|
|
f.userPreferences = config
|
|
sendNotification(t, f, lsproto.WorkspaceDidChangeConfigurationInfo, &lsproto.DidChangeConfigurationParams{
|
|
Settings: map[string]any{
|
|
"js/ts": config,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (f *FourslashTest) ConfigureWithReset(t *testing.T, config lsutil.UserPreferences) (reset func()) {
|
|
originalConfig := f.userPreferences
|
|
f.Configure(t, config)
|
|
return func() {
|
|
f.Configure(t, originalConfig)
|
|
}
|
|
}
|
|
|
|
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) MarkerByName(t *testing.T, name string) *Marker {
|
|
return f.testData.MarkerPositions[name]
|
|
}
|
|
|
|
func (f *FourslashTest) Ranges() []*RangeMarker {
|
|
return f.testData.Ranges
|
|
}
|
|
|
|
func (f *FourslashTest) getRangesInFile(fileName string) []*RangeMarker {
|
|
var rangesInFile []*RangeMarker
|
|
for _, rangeMarker := range f.testData.Ranges {
|
|
if rangeMarker.FileName() == fileName {
|
|
rangesInFile = append(rangesInFile, rangeMarker)
|
|
}
|
|
}
|
|
return rangesInFile
|
|
}
|
|
|
|
func (f *FourslashTest) ensureActiveFile(t *testing.T, filename string) {
|
|
if f.activeFilename != filename {
|
|
if _, ok := f.openFiles[filename]; !ok {
|
|
f.openFile(t, filename)
|
|
} else {
|
|
f.activeFilename = filename
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) CloseFileOfMarker(t *testing.T, markerName string) {
|
|
marker, ok := f.testData.MarkerPositions[markerName]
|
|
if !ok {
|
|
t.Fatalf("Marker '%s' not found", markerName)
|
|
}
|
|
if f.activeFilename == marker.FileName() {
|
|
f.activeFilename = ""
|
|
}
|
|
if index := slices.IndexFunc(f.testData.Files, func(f *TestFileInfo) bool { return f.fileName == marker.FileName() }); index >= 0 {
|
|
testFile := f.testData.Files[index]
|
|
f.scriptInfos[testFile.fileName] = newScriptInfo(testFile.fileName, testFile.Content)
|
|
} else {
|
|
delete(f.scriptInfos, marker.FileName())
|
|
}
|
|
sendNotification(t, f, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(marker.FileName()),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (f *FourslashTest) openFile(t *testing.T, filename string) {
|
|
script := f.getScriptInfo(filename)
|
|
if script == nil {
|
|
if content, ok := f.vfs.ReadFile(filename); ok {
|
|
script = newScriptInfo(filename, content)
|
|
f.scriptInfos[filename] = script
|
|
} else {
|
|
t.Fatalf("File %s not found in test data", filename)
|
|
}
|
|
}
|
|
f.activeFilename = filename
|
|
sendNotification(t, f, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
|
TextDocument: &lsproto.TextDocumentItem{
|
|
Uri: lsconv.FileNameToDocumentURI(filename),
|
|
LanguageId: getLanguageKind(filename),
|
|
Text: script.content,
|
|
},
|
|
})
|
|
f.baselineProjectsAfterNotification(t, filename)
|
|
}
|
|
|
|
func (f *FourslashTest) FormatDocument(t *testing.T, filename string) {
|
|
if filename == "" {
|
|
filename = f.activeFilename
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentFormattingInfo, &lsproto.DocumentFormattingParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(filename),
|
|
},
|
|
Options: f.userPreferences.FormatCodeSettings.ToLSFormatOptions(),
|
|
})
|
|
if result.TextEdits == nil {
|
|
return
|
|
}
|
|
f.applyTextEdits(t, *result.TextEdits)
|
|
}
|
|
|
|
func (f *FourslashTest) FormatSelection(t *testing.T, startMarkerName string, endMarkerName string) {
|
|
t.Helper()
|
|
startMarker, ok := f.testData.MarkerPositions[startMarkerName]
|
|
if !ok {
|
|
t.Fatalf("Marker '%s' not found", startMarkerName)
|
|
}
|
|
endMarker, ok := f.testData.MarkerPositions[endMarkerName]
|
|
if !ok {
|
|
t.Fatalf("Marker '%s' not found", endMarkerName)
|
|
}
|
|
if startMarker.FileName() != endMarker.FileName() {
|
|
t.Fatalf("Markers '%s' and '%s' are in different files", startMarkerName, endMarkerName)
|
|
}
|
|
filename := startMarker.FileName()
|
|
result := sendRequest(t, f, lsproto.TextDocumentRangeFormattingInfo, &lsproto.DocumentRangeFormattingParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(filename),
|
|
},
|
|
Range: lsproto.Range{
|
|
Start: startMarker.LSPosition,
|
|
End: endMarker.LSPosition,
|
|
},
|
|
Options: f.userPreferences.FormatCodeSettings.ToLSFormatOptions(),
|
|
})
|
|
if result.TextEdits == nil {
|
|
return
|
|
}
|
|
f.applyTextEdits(t, *result.TextEdits)
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyCurrentFileContent(t *testing.T, expectedContent string) {
|
|
t.Helper()
|
|
actualContent := f.getScriptInfo(f.activeFilename).content
|
|
assert.Equal(t, actualContent, expectedContent)
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyCurrentLineContent(t *testing.T, expectedContent string) {
|
|
t.Helper()
|
|
actualContent := f.getScriptInfo(f.activeFilename).GetLineContent(int(f.currentCaretPosition.Line))
|
|
assert.Equal(t, actualContent, expectedContent, fmt.Sprintf(
|
|
`
|
|
actual line: "%s"
|
|
expected line: "%s"
|
|
`,
|
|
actualContent,
|
|
expectedContent,
|
|
))
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyIndentation(t *testing.T, numSpaces int) {
|
|
t.Helper()
|
|
// not implemented
|
|
// actualContent := f.getScriptInfo(f.activeFilename).GetLineContent(int(f.currentCaretPosition.Line))
|
|
// assert.Equal(t, actualContent, expectedContent, fmt.Sprintf("Actual line content %s does not match expected content.", actualContent))
|
|
}
|
|
|
|
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 *lsutil.UserPreferences
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type CompletionsExpectedCodeAction struct {
|
|
Name string
|
|
Source string
|
|
Description string
|
|
NewFileContent string
|
|
}
|
|
|
|
type VerifyCompletionsResult struct {
|
|
AndApplyCodeAction func(t *testing.T, expectedAction *CompletionsExpectedCodeAction)
|
|
AndHasNoCodeAction func(t *testing.T, unexpectedAction *CompletionsExpectedCodeAction)
|
|
}
|
|
|
|
// string | *Marker | []string | []*Marker
|
|
type MarkerInput = any
|
|
|
|
// !!! user preferences param
|
|
// !!! completion context param
|
|
func (f *FourslashTest) VerifyCompletions(t *testing.T, markerInput MarkerInput, expected *CompletionsExpectedList) VerifyCompletionsResult {
|
|
t.Helper()
|
|
var list *lsproto.CompletionList
|
|
switch marker := markerInput.(type) {
|
|
case string:
|
|
f.GoToMarker(t, marker)
|
|
list = f.verifyCompletionsWorker(t, expected)
|
|
case *Marker:
|
|
f.goToMarker(t, marker)
|
|
list = 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:
|
|
list = f.verifyCompletionsWorker(t, expected)
|
|
default:
|
|
t.Fatalf("Invalid marker input type: %T. Expected string, *Marker, []string, or []*Marker.", markerInput)
|
|
}
|
|
|
|
return VerifyCompletionsResult{
|
|
AndApplyCodeAction: func(t *testing.T, expectedAction *CompletionsExpectedCodeAction) {
|
|
item := core.Find(list.Items, func(item *lsproto.CompletionItem) bool {
|
|
if item.Label != expectedAction.Name || item.Data == nil {
|
|
return false
|
|
}
|
|
data := item.Data
|
|
if data.AutoImport == nil {
|
|
return false
|
|
}
|
|
return data.AutoImport.ModuleSpecifier == expectedAction.Source
|
|
})
|
|
if item == nil {
|
|
t.Fatalf("Code action '%s' from source '%s' not found in completions.", expectedAction.Name, expectedAction.Source)
|
|
}
|
|
// Detail and AdditionalTextEdits for auto-import items are populated by
|
|
// completionItem/resolve, not in the initial completion list.
|
|
item = f.resolveCompletionItem(t, item)
|
|
assert.Check(t, item.Detail != nil && strings.Contains(*item.Detail, expectedAction.Description), "Completion item detail does not contain expected description.")
|
|
f.applyTextEdits(t, *item.AdditionalTextEdits)
|
|
assert.Equal(t, f.getScriptInfo(f.activeFilename).content, expectedAction.NewFileContent, fmt.Sprintf("File content after applying code action '%s' did not match expected content.", expectedAction.Name))
|
|
},
|
|
AndHasNoCodeAction: func(t *testing.T, unexpectedAction *CompletionsExpectedCodeAction) {
|
|
item := core.Find(list.Items, func(item *lsproto.CompletionItem) bool {
|
|
if item.Label != unexpectedAction.Name || item.Data == nil {
|
|
return false
|
|
}
|
|
data := item.Data
|
|
if data.AutoImport == nil {
|
|
return false
|
|
}
|
|
return data.AutoImport.ModuleSpecifier == unexpectedAction.Source
|
|
})
|
|
if item != nil {
|
|
t.Fatalf("Unexpected code action '%s' from source '%s' found in completions.", unexpectedAction.Name, unexpectedAction.Source)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) verifyCompletionsWorker(t *testing.T, expected *CompletionsExpectedList) *lsproto.CompletionList {
|
|
t.Helper()
|
|
prefix := f.getCurrentPositionPrefix()
|
|
var userPreferences *lsutil.UserPreferences
|
|
if expected != nil {
|
|
userPreferences = expected.UserPreferences
|
|
}
|
|
list := f.getCompletions(t, userPreferences)
|
|
f.verifyCompletionsResult(t, list, expected, prefix)
|
|
return list
|
|
}
|
|
|
|
func (f *FourslashTest) GetCompletions(t *testing.T, userPreferences *lsutil.UserPreferences) *lsproto.CompletionList {
|
|
t.Helper()
|
|
return f.getCompletions(t, userPreferences)
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyJSDocCompletion(t *testing.T, markerInput MarkerInput, expectedOffset int, expectedText string, generateReturnInDocTemplate *bool) {
|
|
t.Helper()
|
|
f.goToMarkerInput(t, markerInput)
|
|
|
|
var userPreferences *lsutil.UserPreferences
|
|
if generateReturnInDocTemplate != nil {
|
|
prefs := lsutil.NewDefaultUserPreferences()
|
|
prefs.GenerateReturnInDocTemplate = core.BoolToTristate(*generateReturnInDocTemplate)
|
|
userPreferences = &prefs
|
|
}
|
|
|
|
list := f.getCompletions(t, userPreferences)
|
|
item := findJSDocCompletionItem(list)
|
|
if item == nil {
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
insertStart := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition))
|
|
f.Insert(t, "/**")
|
|
list = f.getCompletions(t, userPreferences)
|
|
item = findJSDocCompletionItem(list)
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, insertStart, insertStart+3, "")
|
|
f.currentCaretPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(insertStart))
|
|
}
|
|
if list == nil {
|
|
t.Fatalf("%sExpected JSDoc completion, got nil completion list.", f.getCurrentPositionPrefix())
|
|
}
|
|
if item == nil {
|
|
t.Fatalf("%sExpected JSDoc completion item, got %#v.", f.getCurrentPositionPrefix(), list.Items)
|
|
}
|
|
if item.TextEdit == nil || item.TextEdit.InsertReplaceEdit == nil {
|
|
t.Fatalf("%sExpected JSDoc completion to have insert/replace edit, got %#v.", f.getCurrentPositionPrefix(), item.TextEdit)
|
|
}
|
|
assert.Equal(t, item.TextEdit.InsertReplaceEdit.NewText, expectedText, f.getCurrentPositionPrefix())
|
|
_ = expectedOffset // The completion path uses snippet placeholders for caret placement.
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyNoJSDocCompletion(t *testing.T, markerInput MarkerInput) {
|
|
t.Helper()
|
|
f.goToMarkerInput(t, markerInput)
|
|
|
|
list := f.getCompletions(t, nil /*userPreferences*/)
|
|
if item := findJSDocCompletionItem(list); item != nil {
|
|
t.Fatalf("%sDid not expect JSDoc completion item.", f.getCurrentPositionPrefix())
|
|
}
|
|
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
insertStart := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition))
|
|
f.Insert(t, "/**")
|
|
list = f.getCompletions(t, nil /*userPreferences*/)
|
|
item := findJSDocCompletionItem(list)
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, insertStart, insertStart+3, "")
|
|
f.currentCaretPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(insertStart))
|
|
if item != nil {
|
|
t.Fatalf("%sDid not expect JSDoc completion item.", f.getCurrentPositionPrefix())
|
|
}
|
|
}
|
|
|
|
func findJSDocCompletionItem(list *lsproto.CompletionList) *lsproto.CompletionItem {
|
|
if list == nil {
|
|
return nil
|
|
}
|
|
return core.Find(list.Items, func(item *lsproto.CompletionItem) bool {
|
|
return item.Label == "/** */"
|
|
})
|
|
}
|
|
|
|
func (f *FourslashTest) goToMarkerInput(t *testing.T, markerInput MarkerInput) {
|
|
t.Helper()
|
|
switch marker := markerInput.(type) {
|
|
case string:
|
|
f.GoToMarker(t, marker)
|
|
case *Marker:
|
|
f.goToMarker(t, marker)
|
|
default:
|
|
t.Fatalf("Invalid marker input type: %T. Expected string or *Marker.", markerInput)
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) getCompletions(t *testing.T, userPreferences *lsutil.UserPreferences) *lsproto.CompletionList {
|
|
t.Helper()
|
|
params := &lsproto.CompletionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: &lsproto.CompletionContext{},
|
|
}
|
|
if userPreferences != nil {
|
|
reset := f.ConfigureWithReset(t, *userPreferences)
|
|
defer reset()
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentCompletionInfo, params)
|
|
// For performance, the server may return unsorted completion lists.
|
|
// The client is expected to sort them by SortText and then by Label.
|
|
// We are the client here.
|
|
if result.List != nil {
|
|
slices.SortStableFunc(result.List.Items, ls.CompareCompletionEntries)
|
|
}
|
|
return result.List
|
|
}
|
|
|
|
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 {
|
|
if len(actual.Items) == 0 {
|
|
return
|
|
}
|
|
// !!! 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.", len(expected.Exact), len(actual))
|
|
}
|
|
if len(actual) > 0 {
|
|
f.verifyCompletionsAreExactly(t, prefix, actual, expected.Exact)
|
|
}
|
|
return
|
|
}
|
|
nameToActualItems := make(map[string][]*lsproto.CompletionItem)
|
|
for _, item := range actual {
|
|
nameToActualItems[item.Label] = append(nameToActualItems[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 := nameToActualItems[item]
|
|
if !ok {
|
|
t.Fatalf("%sLabel '%s' not found in actual items.", prefix, item)
|
|
}
|
|
delete(nameToActualItems, item)
|
|
case *lsproto.CompletionItem:
|
|
actualItems, ok := nameToActualItems[item.Label]
|
|
if !ok {
|
|
t.Fatalf("%sLabel '%s' not found in actual items.", prefix, item.Label)
|
|
}
|
|
var mismatchPrefix string
|
|
if len(actualItems) > 1 {
|
|
mismatchPrefix = prefix + "No completion item match for label " + item.Label + " (multiple candidates found): "
|
|
} else {
|
|
mismatchPrefix = prefix + "Includes completion item mismatch for label " + item.Label + ": "
|
|
}
|
|
itemIndex := core.FindIndex(actualItems, func(actualItem *lsproto.CompletionItem) bool {
|
|
if err := f.verifyCompletionItem(t, prefix, actualItem, item); err != "" {
|
|
mismatchPrefix += "\n " + err
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
// fail test if no match found
|
|
if itemIndex == -1 {
|
|
t.Fatal(mismatchPrefix)
|
|
}
|
|
|
|
if len(actualItems) == 1 {
|
|
delete(nameToActualItems, item.Label)
|
|
} else if itemIndex == 0 {
|
|
nameToActualItems[item.Label] = actualItems[1:]
|
|
} else if itemIndex == len(actualItems)-1 {
|
|
nameToActualItems[item.Label] = actualItems[:itemIndex]
|
|
} else {
|
|
nameToActualItems[item.Label] = append(actualItems[:itemIndex], actualItems[itemIndex+1:]...)
|
|
}
|
|
|
|
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(nameToActualItems))
|
|
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 := nameToActualItems[item]
|
|
if !ok {
|
|
t.Fatalf("%sLabel '%s' not found in actual items.", prefix, item)
|
|
}
|
|
case *lsproto.CompletionItem:
|
|
actualItems, ok := nameToActualItems[item.Label]
|
|
if !ok {
|
|
t.Fatalf("%sLabel '%s' not found in actual items.", prefix, item.Label)
|
|
}
|
|
|
|
var mismatchPrefix string
|
|
if len(actualItems) > 1 {
|
|
mismatchPrefix = prefix + "No completion item match for label " + item.Label + " (multiple candidates found): "
|
|
} else {
|
|
mismatchPrefix = prefix + "Includes completion item mismatch for label " + item.Label + ": "
|
|
}
|
|
itemIndex := core.FindIndex(actualItems, func(actualItem *lsproto.CompletionItem) bool {
|
|
if err := f.verifyCompletionItem(t, prefix, actualItem, item); err != "" {
|
|
mismatchPrefix += "\n " + err
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
// fail test if no match found
|
|
if itemIndex == -1 {
|
|
t.Fatal(mismatchPrefix)
|
|
}
|
|
|
|
// delete previous entries since we verify entries in order
|
|
if len(actualItems) == 1 || itemIndex == len(actualItems)-1 {
|
|
delete(nameToActualItems, item.Label)
|
|
} else {
|
|
nameToActualItems[item.Label] = actualItems[itemIndex:]
|
|
}
|
|
default:
|
|
t.Fatalf("%sExpected completion item to be a string or *lsproto.CompletionItem, got %T", prefix, item)
|
|
}
|
|
}
|
|
}
|
|
for _, exclude := range expected.Excludes {
|
|
if _, ok := nameToActualItems[exclude]; ok {
|
|
t.Fatalf("%sLabel '%s' should not be in actual items but was found.", prefix, exclude)
|
|
}
|
|
}
|
|
}
|
|
|
|
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:
|
|
if err := f.verifyCompletionItem(t, prefix+"Completion item mismatch for label "+actualItem.Label, actualItem, expectedItem); err != "" {
|
|
t.Fatalf("%s:\n%s", prefix+"Completion item mismatch for label "+actualItem.Label, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func ignorePaths(paths ...string) cmp.Option {
|
|
return cmp.FilterPath(
|
|
func(p cmp.Path) bool {
|
|
return slices.Contains(paths, p.Last().String())
|
|
},
|
|
cmp.Ignore(),
|
|
)
|
|
}
|
|
|
|
var (
|
|
completionIgnoreOpts = ignorePaths(".Kind", ".SortText", ".FilterText", ".Data", ".AdditionalTextEdits")
|
|
autoImportIgnoreOpts = ignorePaths(".Kind", ".SortText", ".FilterText", ".Data", ".LabelDetails", ".Detail", ".AdditionalTextEdits")
|
|
diagnosticsIgnoreOpts = ignorePaths(".Severity", ".Source", ".RelatedInformation")
|
|
)
|
|
|
|
func (f *FourslashTest) verifyCompletionItem(t *testing.T, prefix string, actual *lsproto.CompletionItem, expected *lsproto.CompletionItem) string {
|
|
// returns error message if not matched
|
|
t.Helper()
|
|
var actualAutoImportFix, expectedAutoImportFix *lsproto.AutoImportFix
|
|
if actual.Data != nil {
|
|
actualAutoImportFix = actual.Data.AutoImport
|
|
}
|
|
if expected.Data != nil {
|
|
expectedAutoImportFix = expected.Data.AutoImport
|
|
}
|
|
if (actualAutoImportFix == nil) != (expectedAutoImportFix == nil) {
|
|
return "Mismatch in auto-import data presence"
|
|
}
|
|
|
|
if expected.Detail != nil || expected.Documentation != nil || actualAutoImportFix != nil {
|
|
actual = f.resolveCompletionItem(t, actual)
|
|
}
|
|
|
|
if actualAutoImportFix != nil {
|
|
if err := cmp.Diff(actual, expected, autoImportIgnoreOpts); err != "" {
|
|
return err
|
|
}
|
|
if expected.AdditionalTextEdits == AnyTextEdits {
|
|
if !(actual.AdditionalTextEdits != nil && len(*actual.AdditionalTextEdits) > 0) {
|
|
return "Expected non-nil AdditionalTextEdits for auto-import completion item"
|
|
}
|
|
}
|
|
if expected.LabelDetails != nil {
|
|
if err := cmp.Diff(actual.LabelDetails, expected.LabelDetails); err != "" {
|
|
return fmt.Sprintf("%s:\n%s", "LabelDetailsMismatch", err)
|
|
}
|
|
}
|
|
if actualAutoImportFix.ModuleSpecifier != expectedAutoImportFix.ModuleSpecifier {
|
|
return "ModuleSpecifier mismatch"
|
|
}
|
|
} else {
|
|
if err := cmp.Diff(actual, expected, completionIgnoreOpts); err != "" {
|
|
return err
|
|
}
|
|
if expected.AdditionalTextEdits != AnyTextEdits {
|
|
if err := cmp.Diff(actual.AdditionalTextEdits, expected.AdditionalTextEdits); err != "" {
|
|
return fmt.Sprintf("%s:\n%s", "AdditionalTextEdits mismatch", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if expected.FilterText != nil {
|
|
if err := cmp.Diff(actual.FilterText, expected.FilterText); err != "" {
|
|
return fmt.Sprintf("%s:\n%s", "FilterText mismatch", err)
|
|
}
|
|
}
|
|
if expected.Kind != nil {
|
|
if err := cmp.Diff(actual.Kind, expected.Kind); err != "" {
|
|
return fmt.Sprintf("%s:\n%s", "Kind mismatch", err)
|
|
}
|
|
}
|
|
if err := cmp.Diff(actual.SortText, core.OrElse(expected.SortText, new(string(ls.SortTextLocationPriority)))); err != "" {
|
|
return fmt.Sprintf("%s:\n%s", "SortText mismatch", err)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (f *FourslashTest) ResolveCompletionItem(t *testing.T, item *lsproto.CompletionItem) *lsproto.CompletionItem {
|
|
t.Helper()
|
|
return f.resolveCompletionItem(t, item)
|
|
}
|
|
|
|
func (f *FourslashTest) resolveCompletionItem(t *testing.T, item *lsproto.CompletionItem) *lsproto.CompletionItem {
|
|
result := sendRequest(t, f, lsproto.CompletionItemResolveInfo, item)
|
|
return result
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// VerifyCodeFixOptions are the options for VerifyCodeFix.
|
|
type VerifyCodeFixOptions struct {
|
|
Description string
|
|
NewFileContent string
|
|
NewRangeContent string
|
|
Index int
|
|
ApplyChanges bool
|
|
UserPreferences *lsutil.UserPreferences
|
|
}
|
|
|
|
// VerifyCodeFixAllOptions are the options for VerifyCodeFixAll.
|
|
type VerifyCodeFixAllOptions struct {
|
|
FixID string
|
|
NewFileContent string
|
|
}
|
|
|
|
// VerifyCodeFix verifies that applying a code fix produces the expected file content.
|
|
func (f *FourslashTest) VerifyCodeFix(t *testing.T, options VerifyCodeFixOptions) {
|
|
t.Helper()
|
|
|
|
if options.UserPreferences != nil {
|
|
reset := f.ConfigureWithReset(t, *options.UserPreferences)
|
|
defer reset()
|
|
}
|
|
|
|
actions := f.getCodeFixActions(t)
|
|
|
|
if len(actions) == 0 {
|
|
t.Fatalf("No code fixes returned.")
|
|
}
|
|
if options.Index >= len(actions) {
|
|
t.Fatalf("Code fix index %d out of range (got %d fixes)", options.Index, len(actions))
|
|
}
|
|
|
|
matchingAction := actions[options.Index]
|
|
if matchingAction.Title != options.Description {
|
|
found := false
|
|
for _, action := range actions {
|
|
if action.Title == options.Description {
|
|
matchingAction = action
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
var titles []string
|
|
for _, a := range actions {
|
|
titles = append(titles, a.Title)
|
|
}
|
|
t.Fatalf("No code fix with description %q at index %d found. Available fixes: %v", options.Description, options.Index, titles)
|
|
}
|
|
}
|
|
|
|
originalContent := f.getScriptInfo(f.activeFilename).content
|
|
expectedContent := options.NewFileContent
|
|
if options.NewRangeContent != "" {
|
|
selection := f.getSelection()
|
|
if selection.Pos() == selection.End() {
|
|
ranges := f.getRangesInFile(f.activeFilename)
|
|
if len(ranges) == 0 {
|
|
t.Fatal("Expected a selected range or fourslash range for NewRangeContent verification.")
|
|
}
|
|
selection = ranges[0].Range
|
|
}
|
|
expectedContent = originalContent[:selection.Pos()] + options.NewRangeContent + originalContent[selection.End():]
|
|
}
|
|
|
|
if options.ApplyChanges {
|
|
if matchingAction.Edit != nil && matchingAction.Edit.Changes != nil {
|
|
expectedURI := lsconv.FileNameToDocumentURI(f.activeFilename)
|
|
for uri, edits := range *matchingAction.Edit.Changes {
|
|
if uri != expectedURI {
|
|
t.Fatalf("Code fix returned edits for unexpected URI %q (expected %q)", uri, expectedURI)
|
|
}
|
|
f.applyTextEdits(t, edits)
|
|
}
|
|
}
|
|
actual := f.getScriptInfo(f.activeFilename).content
|
|
assert.Equal(t, expectedContent, actual, "File content after applying code fix did not match expected content.")
|
|
} else {
|
|
actual := f.getScriptInfo(f.activeFilename).content
|
|
if matchingAction.Edit != nil && matchingAction.Edit.Changes != nil {
|
|
expectedURI := lsconv.FileNameToDocumentURI(f.activeFilename)
|
|
for uri, edits := range *matchingAction.Edit.Changes {
|
|
if uri != expectedURI {
|
|
t.Fatalf("Code fix returned edits for unexpected URI %q (expected %q)", uri, expectedURI)
|
|
}
|
|
actual = f.applyEditsToContent(actual, edits)
|
|
}
|
|
}
|
|
assert.Equal(t, expectedContent, actual, "File content after applying code fix did not match expected content.")
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyRangeAfterCodeFix(t *testing.T, expectedText string, includeWhitespace bool, errorCode int, index int) {
|
|
t.Helper()
|
|
|
|
actions := f.getCodeFixActions(t, errorCode)
|
|
if len(actions) == 0 {
|
|
t.Fatalf("No code fixes returned.")
|
|
}
|
|
|
|
if index >= len(actions) {
|
|
t.Fatalf("Code fix index %d out of range (got %d fixes)", index, len(actions))
|
|
}
|
|
|
|
action := actions[index]
|
|
ranges := f.getRangesInFile(f.activeFilename)
|
|
if len(ranges) != 1 {
|
|
t.Fatalf("Expected exactly one range in %q, got %d.", f.activeFilename, len(ranges))
|
|
}
|
|
|
|
edits := f.getCodeActionEditsForActiveFile(t, action)
|
|
updatedRange := f.updateTextRangeForTextEdits(ranges[0].Range, edits)
|
|
assertValidTextRange(t, updatedRange, fmt.Sprintf("Code fix %q replaced part of the expected range; unable to compute rangeAfterCodeFix result.", action.Title))
|
|
|
|
f.applyTextEdits(t, edits)
|
|
actualContent := f.getScriptInfo(f.activeFilename).content
|
|
actualText := actualContent[updatedRange.Pos():updatedRange.End()]
|
|
|
|
if includeWhitespace {
|
|
assert.Equal(t, expectedText, actualText, "Range content after applying code fix did not match expected content.")
|
|
return
|
|
}
|
|
|
|
actualText = removeWhitespace(actualText)
|
|
expectedText = removeWhitespace(expectedText)
|
|
assert.Equal(t, expectedText, actualText, "Range content after applying code fix did not match expected content.")
|
|
}
|
|
|
|
func (f *FourslashTest) getCodeActionEditsForActiveFile(t *testing.T, action *lsproto.CodeAction) []*lsproto.TextEdit {
|
|
t.Helper()
|
|
if action.Edit == nil || action.Edit.Changes == nil {
|
|
t.Fatalf("Code fix %q did not return text edits.", action.Title)
|
|
}
|
|
if len(*action.Edit.Changes) != 1 {
|
|
t.Fatalf("Code fix %q returned edits for multiple files; rangeAfterCodeFix expects only the active file.", action.Title)
|
|
}
|
|
|
|
edits, ok := (*action.Edit.Changes)[lsconv.FileNameToDocumentURI(f.activeFilename)]
|
|
if ok {
|
|
return edits
|
|
}
|
|
t.Fatalf("Code fix %q did not return edits for active file %q.", action.Title, f.activeFilename)
|
|
panic("unreachable")
|
|
}
|
|
|
|
// VerifyCodeFixAvailable verifies that code fixes with the given descriptions are available.
|
|
func (f *FourslashTest) VerifyCodeFixAvailable(t *testing.T, expectedDescriptions []string) {
|
|
t.Helper()
|
|
|
|
actions := f.getCodeFixActions(t)
|
|
|
|
if expectedDescriptions == nil {
|
|
if len(actions) == 0 {
|
|
t.Fatalf("Expected code fixes to be available, but got none.")
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(expectedDescriptions) == 0 {
|
|
f.VerifyCodeFixNotAvailable(t)
|
|
return
|
|
}
|
|
|
|
for _, expected := range expectedDescriptions {
|
|
found := false
|
|
for _, action := range actions {
|
|
if action.Title == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
var titles []string
|
|
for _, a := range actions {
|
|
titles = append(titles, a.Title)
|
|
}
|
|
t.Fatalf("Expected code fix with description %q not found. Available fixes: %v", expected, titles)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyCodeFixNotAvailable(t *testing.T, expected ...string) {
|
|
t.Helper()
|
|
|
|
actions := f.getCodeFixActions(t)
|
|
if len(expected) == 0 {
|
|
if len(actions) == 0 {
|
|
return
|
|
}
|
|
|
|
var titles []string
|
|
for _, action := range actions {
|
|
titles = append(titles, action.Title)
|
|
}
|
|
t.Fatalf("Expected no code fixes, but got: %v", titles)
|
|
}
|
|
for _, title := range expected {
|
|
for _, action := range actions {
|
|
if action.Title == title {
|
|
t.Fatalf("Expected code fix with description %q not to be available.", title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// VerifyCodeFixAvailableExact verifies that the exact set of code fix descriptions matches.
|
|
// Unlike VerifyCodeFixAvailable, this checks both that all expected descriptions are present
|
|
// and that no additional unexpected code fixes exist (exact count match).
|
|
func (f *FourslashTest) VerifyCodeFixAvailableExact(t *testing.T, expectedDescriptions []string) {
|
|
t.Helper()
|
|
|
|
actions := f.getCodeFixActions(t)
|
|
|
|
if len(actions) != len(expectedDescriptions) {
|
|
var titles []string
|
|
for _, a := range actions {
|
|
titles = append(titles, a.Title)
|
|
}
|
|
t.Fatalf("Expected exactly %d code fixes, but got %d. Available fixes: %v", len(expectedDescriptions), len(actions), titles)
|
|
}
|
|
|
|
for _, expected := range expectedDescriptions {
|
|
found := false
|
|
for _, action := range actions {
|
|
if action.Title == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
var titles []string
|
|
for _, a := range actions {
|
|
titles = append(titles, a.Title)
|
|
}
|
|
t.Fatalf("Expected code fix with description %q not found. Available fixes: %v", expected, titles)
|
|
}
|
|
}
|
|
}
|
|
|
|
// VerifyCodeFixAll verifies that applying all code fixes with the given fixId produces the expected file content.
|
|
// It gets all quickfix code actions for the file (which includes per-fixId "Fix all" entries when
|
|
// multiple diagnostics match the same provider), finds the fix-all entry, and applies its edits.
|
|
func (f *FourslashTest) VerifyCodeFixAll(t *testing.T, options VerifyCodeFixAllOptions) {
|
|
t.Helper()
|
|
|
|
actions := f.getAllQuickFixActions(t)
|
|
if len(actions) == 0 {
|
|
t.Fatalf("No code fixes available for fixId %q", options.FixID)
|
|
}
|
|
|
|
// Find fix-all actions. The server returns these as quickfix entries with titles like
|
|
// "Add all missing imports" when multiple diagnostics match the same provider.
|
|
// We look for actions that are NOT single-diagnostic fixes (i.e., have no Diagnostics attached).
|
|
var fixAllCandidates []*lsproto.CodeAction
|
|
for _, action := range actions {
|
|
if action.Diagnostics == nil || len(*action.Diagnostics) == 0 {
|
|
fixAllCandidates = append(fixAllCandidates, action)
|
|
}
|
|
}
|
|
|
|
var fixAllAction *lsproto.CodeAction
|
|
if len(fixAllCandidates) == 1 {
|
|
fixAllAction = fixAllCandidates[0]
|
|
} else {
|
|
// If there are multiple fix-all candidates, match by FixID in the title.
|
|
for _, action := range fixAllCandidates {
|
|
if strings.Contains(strings.ToLower(action.Title), strings.ToLower(options.FixID)) {
|
|
fixAllAction = action
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if fixAllAction == nil {
|
|
var titles []string
|
|
for _, a := range actions {
|
|
titles = append(titles, a.Title)
|
|
}
|
|
t.Fatalf("No fix-all code action found for fixId %q. Available fixes: %v", options.FixID, titles)
|
|
}
|
|
|
|
if fixAllAction.Edit != nil && fixAllAction.Edit.Changes != nil {
|
|
expectedURI := lsconv.FileNameToDocumentURI(f.activeFilename)
|
|
for uri, edits := range *fixAllAction.Edit.Changes {
|
|
if uri != expectedURI {
|
|
t.Fatalf("Fix-all code action returned edits for unexpected URI %q (expected %q)", uri, expectedURI)
|
|
}
|
|
f.applyTextEdits(t, edits)
|
|
}
|
|
}
|
|
|
|
actual := f.getScriptInfo(f.activeFilename).content
|
|
assert.Equal(t, options.NewFileContent, actual, "File content after applying all code fixes did not match expected content.")
|
|
}
|
|
|
|
// VerifySourceFixAll verifies that requesting a source.fixAll code action produces the expected file content.
|
|
// This tests the on-save code path where VS Code requests source.fixAll.
|
|
func (f *FourslashTest) VerifySourceFixAll(t *testing.T, expectedContent string) {
|
|
t.Helper()
|
|
|
|
only := []lsproto.CodeActionKind{lsproto.CodeActionKindSourceFixAll}
|
|
params := &lsproto.CodeActionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Range: lsproto.Range{
|
|
Start: f.currentCaretPosition,
|
|
End: f.currentCaretPosition,
|
|
},
|
|
Context: &lsproto.CodeActionContext{
|
|
Diagnostics: []*lsproto.Diagnostic{},
|
|
Only: &only,
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params)
|
|
|
|
if result.CommandOrCodeActionArray == nil {
|
|
t.Fatalf("No source.fixAll code actions returned")
|
|
}
|
|
|
|
var selected *lsproto.CodeAction
|
|
for _, item := range *result.CommandOrCodeActionArray {
|
|
if item.CodeAction == nil || item.CodeAction.Kind == nil || *item.CodeAction.Kind != lsproto.CodeActionKindSourceFixAll {
|
|
continue
|
|
}
|
|
selected = item.CodeAction
|
|
break
|
|
}
|
|
|
|
if selected == nil {
|
|
t.Fatalf("No source.fixAll code action found")
|
|
}
|
|
if selected.Edit != nil && selected.Edit.Changes != nil {
|
|
expectedURI := lsconv.FileNameToDocumentURI(f.activeFilename)
|
|
for uri, edits := range *selected.Edit.Changes {
|
|
if uri != expectedURI {
|
|
t.Fatalf("source.fixAll returned edits for unexpected URI %q (expected %q)", uri, expectedURI)
|
|
}
|
|
f.applyTextEdits(t, edits)
|
|
}
|
|
}
|
|
|
|
actual := f.getScriptInfo(f.activeFilename).content
|
|
assert.Equal(t, expectedContent, actual, "File content after source.fixAll did not match expected content.")
|
|
}
|
|
|
|
// getCodeFixActions gets per-diagnostic quick fix code actions, excluding fix-all entries.
|
|
func (f *FourslashTest) getCodeFixActions(t *testing.T, errorCode ...int) []*lsproto.CodeAction {
|
|
t.Helper()
|
|
all := f.getAllQuickFixActions(t, errorCode...)
|
|
// Filter to only per-diagnostic fixes (those with diagnostics attached)
|
|
var actions []*lsproto.CodeAction
|
|
for _, action := range all {
|
|
if action.Diagnostics != nil && len(*action.Diagnostics) > 0 {
|
|
actions = append(actions, action)
|
|
}
|
|
}
|
|
return actions
|
|
}
|
|
|
|
// getAllQuickFixActions gets all quick fix code actions including fix-all entries.
|
|
func (f *FourslashTest) getAllQuickFixActions(t *testing.T, errorCode ...int) []*lsproto.CodeAction {
|
|
t.Helper()
|
|
|
|
diagParams := &lsproto.DocumentDiagnosticParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
}
|
|
diagResult := sendRequest(t, f, lsproto.TextDocumentDiagnosticInfo, diagParams)
|
|
|
|
var diagnostics []*lsproto.Diagnostic
|
|
if diagResult.FullDocumentDiagnosticReport != nil && diagResult.FullDocumentDiagnosticReport.Items != nil {
|
|
diagnostics = diagResult.FullDocumentDiagnosticReport.Items
|
|
}
|
|
|
|
if len(diagnostics) == 0 {
|
|
return nil
|
|
}
|
|
|
|
diagnostic := selectCodeFixDiagnostic(diagnostics, core.FirstOrNil(errorCode))
|
|
if diagnostic == nil {
|
|
return nil
|
|
}
|
|
|
|
params := &lsproto.CodeActionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Range: lsproto.Range{
|
|
Start: diagnostic.Range.Start,
|
|
End: diagnostic.Range.End,
|
|
},
|
|
Context: &lsproto.CodeActionContext{
|
|
Diagnostics: diagnostics,
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params)
|
|
|
|
var actions []*lsproto.CodeAction
|
|
if result.CommandOrCodeActionArray != nil {
|
|
for _, item := range *result.CommandOrCodeActionArray {
|
|
if item.CodeAction != nil && item.CodeAction.Kind != nil && *item.CodeAction.Kind == lsproto.CodeActionKindQuickFix {
|
|
actions = append(actions, item.CodeAction)
|
|
}
|
|
}
|
|
}
|
|
|
|
return actions
|
|
}
|
|
|
|
func (f *FourslashTest) updateTextRangeForTextEdits(textRange core.TextRange, edits []*lsproto.TextEdit) core.TextRange {
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
spans := make([]textEditSpan, 0, len(edits))
|
|
for _, edit := range edits {
|
|
spans = append(spans, textEditSpan{
|
|
start: int(f.converters.LineAndCharacterToPosition(script, edit.Range.Start)),
|
|
end: int(f.converters.LineAndCharacterToPosition(script, edit.Range.End)),
|
|
length: len(edit.NewText),
|
|
})
|
|
}
|
|
slices.SortFunc(spans, func(a, b textEditSpan) int {
|
|
return a.start - b.start
|
|
})
|
|
|
|
pos := textRange.Pos()
|
|
end := textRange.End()
|
|
for i, edit := range spans {
|
|
pos = updatePositionForTextEdit(pos, edit.start, edit.end, edit.length)
|
|
end = updatePositionForTextEdit(end, edit.start, edit.end, edit.length)
|
|
|
|
delta := edit.length - (edit.end - edit.start)
|
|
for j := i + 1; j < len(spans); j++ {
|
|
if spans[j].start >= edit.start {
|
|
spans[j].start += delta
|
|
spans[j].end += delta
|
|
}
|
|
}
|
|
}
|
|
return core.NewTextRange(pos, end)
|
|
}
|
|
|
|
// applyEditsToContent applies text edits to a content string without mutating the file.
|
|
func (f *FourslashTest) applyEditsToContent(content string, edits []*lsproto.TextEdit) string {
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
slices.SortFunc(edits, func(a, b *lsproto.TextEdit) int {
|
|
aStart := f.converters.LineAndCharacterToPosition(script, a.Range.Start)
|
|
bStart := f.converters.LineAndCharacterToPosition(script, b.Range.Start)
|
|
return int(aStart) - int(bStart)
|
|
})
|
|
for i := len(edits) - 1; i >= 0; i-- {
|
|
edit := edits[i]
|
|
start := int(f.converters.LineAndCharacterToPosition(script, edit.Range.Start))
|
|
end := int(f.converters.LineAndCharacterToPosition(script, edit.Range.End))
|
|
content = content[:start] + edit.NewText + content[end:]
|
|
}
|
|
return content
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyOrganizeImports(t *testing.T, expectedContent string, codeActionKind lsproto.CodeActionKind, preferences *lsutil.UserPreferences) {
|
|
t.Helper()
|
|
|
|
if preferences != nil {
|
|
reset := f.ConfigureWithReset(t, *preferences)
|
|
defer reset()
|
|
}
|
|
|
|
params := &lsproto.CodeActionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Range: lsproto.Range{
|
|
Start: lsproto.Position{Line: 0, Character: 0},
|
|
End: f.converters.PositionToLineAndCharacter(f.getScriptInfo(f.activeFilename), core.TextPos(len(f.getScriptInfo(f.activeFilename).content))),
|
|
},
|
|
Context: &lsproto.CodeActionContext{
|
|
Only: &[]lsproto.CodeActionKind{codeActionKind},
|
|
},
|
|
}
|
|
|
|
result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params)
|
|
|
|
if result.CommandOrCodeActionArray == nil || len(*result.CommandOrCodeActionArray) == 0 {
|
|
t.Fatalf("No organize imports code action found")
|
|
}
|
|
|
|
var organizeAction *lsproto.CodeAction
|
|
for _, item := range *result.CommandOrCodeActionArray {
|
|
if item.CodeAction != nil && item.CodeAction.Kind != nil && *item.CodeAction.Kind == codeActionKind {
|
|
organizeAction = item.CodeAction
|
|
break
|
|
}
|
|
}
|
|
|
|
if organizeAction == nil {
|
|
t.Fatalf("No organize imports code action found")
|
|
}
|
|
|
|
expectedURI := lsconv.FileNameToDocumentURI(f.activeFilename)
|
|
if organizeAction.Edit != nil && organizeAction.Edit.Changes != nil {
|
|
for uri, edits := range *organizeAction.Edit.Changes {
|
|
if uri != expectedURI {
|
|
t.Fatalf("Organize imports changed unexpected file: %s (expected %s)", uri, expectedURI)
|
|
}
|
|
f.applyTextEdits(t, edits)
|
|
}
|
|
}
|
|
|
|
actualContent := f.getScriptInfo(f.activeFilename).content
|
|
if actualContent != expectedContent {
|
|
t.Fatalf("Organize imports result doesn't match.\nExpected:\n%s\n\nActual:\n%s", expectedContent, actualContent)
|
|
}
|
|
}
|
|
|
|
type ApplyCodeActionFromCompletionOptions struct {
|
|
Name string
|
|
Source string
|
|
AutoImportFix *lsproto.AutoImportFix
|
|
Description string
|
|
NewFileContent *string
|
|
NewRangeContent *string
|
|
UserPreferences *lsutil.UserPreferences
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyApplyCodeActionFromCompletion(t *testing.T, markerName *string, options *ApplyCodeActionFromCompletionOptions) {
|
|
t.Helper()
|
|
f.GoToMarker(t, *markerName)
|
|
var userPreferences *lsutil.UserPreferences
|
|
if options != nil && options.UserPreferences != nil {
|
|
userPreferences = options.UserPreferences
|
|
} else {
|
|
// Default preferences: enables auto-imports
|
|
userPreferences = new(lsutil.NewDefaultUserPreferences())
|
|
}
|
|
|
|
reset := f.ConfigureWithReset(t, *userPreferences)
|
|
defer reset()
|
|
completionsList := f.getCompletions(t, nil) // Already configured, so we do not need to pass it in again
|
|
items := core.Filter(completionsList.Items, func(item *lsproto.CompletionItem) bool {
|
|
if item.Label != options.Name || item.Data == nil {
|
|
return false
|
|
}
|
|
|
|
data := item.Data
|
|
if options.AutoImportFix != nil {
|
|
return data.AutoImport != nil &&
|
|
(options.AutoImportFix.ModuleSpecifier == "" || data.AutoImport.ModuleSpecifier == options.AutoImportFix.ModuleSpecifier)
|
|
}
|
|
if data.AutoImport == nil && data.Source != "" && data.Source == options.Source {
|
|
return true
|
|
}
|
|
if data.AutoImport != nil && data.AutoImport.ModuleSpecifier == options.Source {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
if len(items) == 0 {
|
|
t.Fatalf("Code action '%s' from source '%s' not found in completions.", options.Name, options.Source)
|
|
}
|
|
|
|
var correctResolvedItem lsproto.CompletionItem
|
|
correctItem := core.Find(items, func(item *lsproto.CompletionItem) bool {
|
|
correctResolvedItem = *f.resolveCompletionItem(t, item)
|
|
var actualDetail string
|
|
if correctResolvedItem.Detail != nil {
|
|
actualDetail = *correctResolvedItem.Detail
|
|
}
|
|
if !strings.Contains(actualDetail, options.Description) || correctResolvedItem.AdditionalTextEdits == nil {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
if correctItem == nil {
|
|
t.Fatalf("No matching code action found for '%s' from source '%s'.", options.Name, options.Source)
|
|
var actualDetail string
|
|
if correctResolvedItem.Detail != nil {
|
|
actualDetail = *correctResolvedItem.Detail
|
|
}
|
|
assert.Check(t, strings.Contains(actualDetail, options.Description), "Completion item detail does not contain expected description.")
|
|
if correctResolvedItem.AdditionalTextEdits == nil {
|
|
t.Fatalf("Expected non-nil AdditionalTextEdits for code action completion item.")
|
|
}
|
|
}
|
|
|
|
// apply the item to the test files
|
|
f.applyTextEdits(t, *correctResolvedItem.AdditionalTextEdits)
|
|
if options.NewFileContent != nil {
|
|
assert.Equal(t, f.getScriptInfo(f.activeFilename).content, *options.NewFileContent, "File content after applying code action did not match expected content.")
|
|
} else if options.NewRangeContent != nil {
|
|
t.Fatal("!!! TODO")
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyImportFixAtPosition(t *testing.T, expectedTexts []string, preferences *lsutil.UserPreferences) {
|
|
t.Helper()
|
|
fileName := f.activeFilename
|
|
ranges := f.Ranges()
|
|
var filteredRanges []*RangeMarker
|
|
for _, r := range ranges {
|
|
if r.FileName() == fileName {
|
|
filteredRanges = append(filteredRanges, r)
|
|
}
|
|
}
|
|
if len(filteredRanges) > 1 {
|
|
t.Fatalf("Exactly one range should be specified in the testfile.")
|
|
}
|
|
var rangeMarker *RangeMarker
|
|
if len(filteredRanges) == 1 {
|
|
rangeMarker = filteredRanges[0]
|
|
}
|
|
|
|
if preferences != nil {
|
|
reset := f.ConfigureWithReset(t, *preferences)
|
|
defer reset()
|
|
}
|
|
|
|
// Get diagnostics at the current position to find errors that need import fixes
|
|
diagParams := &lsproto.DocumentDiagnosticParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
}
|
|
diagResult := sendRequest(t, f, lsproto.TextDocumentDiagnosticInfo, diagParams)
|
|
|
|
var diagnostics []*lsproto.Diagnostic
|
|
if diagResult.FullDocumentDiagnosticReport != nil && diagResult.FullDocumentDiagnosticReport.Items != nil {
|
|
diagnostics = diagResult.FullDocumentDiagnosticReport.Items
|
|
}
|
|
|
|
currentCaretPosition := f.currentCaretPosition
|
|
params := &lsproto.CodeActionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Range: lsproto.Range{
|
|
End: currentCaretPosition,
|
|
Start: currentCaretPosition,
|
|
},
|
|
Context: &lsproto.CodeActionContext{
|
|
Diagnostics: diagnostics,
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params)
|
|
|
|
// Find all auto-import code actions (fixes with fixId/fixName related to imports)
|
|
// Skip fix-all entries (those without diagnostics attached)
|
|
var importActions []*lsproto.CodeAction
|
|
if result.CommandOrCodeActionArray != nil {
|
|
for _, item := range *result.CommandOrCodeActionArray {
|
|
if item.CodeAction != nil && item.CodeAction.Kind != nil && *item.CodeAction.Kind == lsproto.CodeActionKindQuickFix {
|
|
if item.CodeAction.Diagnostics != nil && len(*item.CodeAction.Diagnostics) > 0 {
|
|
importActions = append(importActions, item.CodeAction)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(importActions) == 0 {
|
|
if len(expectedTexts) != 0 {
|
|
t.Fatalf("No codefixes returned.")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Save the original content before any edits
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
originalContent := script.content
|
|
// For each import action, apply it and check the result
|
|
actualTextArray := make([]string, 0, len(importActions))
|
|
for _, action := range importActions {
|
|
// Apply the code action
|
|
if action.Edit != nil && action.Edit.Changes != nil {
|
|
if len(*action.Edit.Changes) != 1 {
|
|
t.Fatalf("Expected exactly 1 change, got %d", len(*action.Edit.Changes))
|
|
}
|
|
for uri, changeEdits := range *action.Edit.Changes {
|
|
if uri != lsconv.FileNameToDocumentURI(f.activeFilename) {
|
|
t.Fatalf("Expected change to file %s, got %s", f.activeFilename, uri)
|
|
}
|
|
f.applyTextEdits(t, changeEdits)
|
|
}
|
|
}
|
|
|
|
// Get the result text
|
|
var text string
|
|
if rangeMarker != nil {
|
|
text = f.getRangeText(rangeMarker)
|
|
} else {
|
|
text = f.getScriptInfo(f.activeFilename).content
|
|
}
|
|
actualTextArray = append(actualTextArray, text)
|
|
|
|
// Restore original content for next fix
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, 0, len(script.content), originalContent)
|
|
f.currentCaretPosition = currentCaretPosition
|
|
}
|
|
|
|
// Compare results
|
|
if len(expectedTexts) != len(actualTextArray) {
|
|
var actualJoined strings.Builder
|
|
for i, actual := range actualTextArray {
|
|
if i > 0 {
|
|
actualJoined.WriteString("\n\n")
|
|
actualJoined.WriteString(strings.Repeat("-", 20))
|
|
actualJoined.WriteString("\n\n")
|
|
}
|
|
actualJoined.WriteString(actual)
|
|
}
|
|
t.Fatalf("Expected %d import fixes, got %d:\n\n%s", len(expectedTexts), len(actualTextArray), actualJoined.String())
|
|
}
|
|
for i, expected := range expectedTexts {
|
|
actual := actualTextArray[i]
|
|
assert.Equal(t, expected, actual, fmt.Sprintf("Import fix at index %d doesn't match.\n", i))
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyImportFixModuleSpecifiers(
|
|
t *testing.T,
|
|
markerName string,
|
|
expectedModuleSpecifiers []string,
|
|
preferences *lsutil.UserPreferences,
|
|
) {
|
|
t.Helper()
|
|
f.GoToMarker(t, markerName)
|
|
|
|
if preferences != nil {
|
|
reset := f.ConfigureWithReset(t, *preferences)
|
|
defer reset()
|
|
}
|
|
|
|
// Get diagnostics at the current position to find errors that need import fixes
|
|
diagParams := &lsproto.DocumentDiagnosticParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
}
|
|
diagResult := sendRequest(t, f, lsproto.TextDocumentDiagnosticInfo, diagParams)
|
|
|
|
var diagnostics []*lsproto.Diagnostic
|
|
if diagResult.FullDocumentDiagnosticReport != nil && diagResult.FullDocumentDiagnosticReport.Items != nil {
|
|
diagnostics = diagResult.FullDocumentDiagnosticReport.Items
|
|
}
|
|
|
|
params := &lsproto.CodeActionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Range: lsproto.Range{
|
|
Start: f.currentCaretPosition,
|
|
End: f.currentCaretPosition,
|
|
},
|
|
Context: &lsproto.CodeActionContext{
|
|
Diagnostics: diagnostics,
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params)
|
|
|
|
// Extract module specifiers from import fix code actions
|
|
var actualModuleSpecifiers []string
|
|
if result.CommandOrCodeActionArray != nil {
|
|
for _, item := range *result.CommandOrCodeActionArray {
|
|
if item.CodeAction != nil && item.CodeAction.Kind != nil && *item.CodeAction.Kind == lsproto.CodeActionKindQuickFix {
|
|
if item.CodeAction.Edit != nil && item.CodeAction.Edit.Changes != nil {
|
|
for _, changeEdits := range *item.CodeAction.Edit.Changes {
|
|
for _, edit := range changeEdits {
|
|
moduleSpec := extractModuleSpecifier(edit.NewText)
|
|
if moduleSpec != "" {
|
|
if !slices.Contains(actualModuleSpecifiers, moduleSpec) {
|
|
actualModuleSpecifiers = append(actualModuleSpecifiers, moduleSpec)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compare results
|
|
if len(actualModuleSpecifiers) != len(expectedModuleSpecifiers) {
|
|
t.Fatalf("Expected %d module specifiers, got %d.\nExpected: %v\nActual: %v",
|
|
len(expectedModuleSpecifiers), len(actualModuleSpecifiers),
|
|
expectedModuleSpecifiers, actualModuleSpecifiers)
|
|
}
|
|
|
|
for i, expected := range expectedModuleSpecifiers {
|
|
if i >= len(actualModuleSpecifiers) || actualModuleSpecifiers[i] != expected {
|
|
t.Fatalf("Module specifier mismatch at index %d.\nExpected: %v\nActual: %v",
|
|
i, expectedModuleSpecifiers, actualModuleSpecifiers)
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractModuleSpecifier(text string) string {
|
|
// Try to match: from "..." or from '...'
|
|
if idx := strings.Index(text, "from \""); idx != -1 {
|
|
start := idx + 6 // len("from \"")
|
|
if end := strings.Index(text[start:], "\""); end != -1 {
|
|
return text[start : start+end]
|
|
}
|
|
}
|
|
if idx := strings.Index(text, "from '"); idx != -1 {
|
|
start := idx + 6 // len("from '")
|
|
if end := strings.Index(text[start:], "'"); end != -1 {
|
|
return text[start : start+end]
|
|
}
|
|
}
|
|
|
|
// Try to match: require("...") or require('...')
|
|
if idx := strings.Index(text, "require(\""); idx != -1 {
|
|
start := idx + 9 // len("require(\"")
|
|
if end := strings.Index(text[start:], "\""); end != -1 {
|
|
return text[start : start+end]
|
|
}
|
|
}
|
|
if idx := strings.Index(text, "require('"); idx != -1 {
|
|
start := idx + 9 // len("require('")
|
|
if end := strings.Index(text[start:], "'"); end != -1 {
|
|
return text[start : start+end]
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
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: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: &lsproto.ReferenceContext{
|
|
IncludeDeclaration: true,
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentReferencesInfo, params)
|
|
f.addResultToBaseline(t, findAllReferencesCmd, f.getBaselineForLocationsWithFileContents(*result.Locations, baselineFourslashLocationsOptions{
|
|
marker: markerOrRange,
|
|
markerName: "/*FIND ALL REFS*/",
|
|
}))
|
|
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineVSFindAllReferences(
|
|
t *testing.T,
|
|
markers ...string,
|
|
) {
|
|
referenceLocations := f.lookupMarkersOrGetRanges(t, markers)
|
|
|
|
for _, markerOrRange := range referenceLocations {
|
|
f.GoToMarkerOrRange(t, markerOrRange)
|
|
|
|
params := &lsproto.ReferenceParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: &lsproto.ReferenceContext{
|
|
IncludeDeclaration: true,
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentVSReferencesInfo, params)
|
|
// Sort cross-project results for deterministic baselines
|
|
if result.VSReferenceItems != nil && len(*result.VSReferenceItems) > 0 {
|
|
items := *result.VSReferenceItems
|
|
slices.SortStableFunc(items, func(a, b *lsproto.VSReferenceItem) int {
|
|
ap, bp := "", ""
|
|
if a.VSProjectName != nil {
|
|
ap = *a.VSProjectName
|
|
}
|
|
if b.VSProjectName != nil {
|
|
bp = *b.VSProjectName
|
|
}
|
|
if ap != bp {
|
|
if ap < bp {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if a.VSLocation.Uri != b.VSLocation.Uri {
|
|
if string(a.VSLocation.Uri) < string(b.VSLocation.Uri) {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if a.VSLocation.Range.Start.Line != b.VSLocation.Range.Start.Line {
|
|
return int(a.VSLocation.Range.Start.Line) - int(b.VSLocation.Range.Start.Line)
|
|
}
|
|
return int(a.VSLocation.Range.Start.Character) - int(b.VSLocation.Range.Start.Character)
|
|
})
|
|
// Re-number IDs sequentially after sort
|
|
idRemap := make(map[int32]int32, len(items))
|
|
for i, item := range items {
|
|
idRemap[item.VSId] = int32(i)
|
|
item.VSId = int32(i)
|
|
}
|
|
for _, item := range items {
|
|
if item.VSDefinitionId != nil {
|
|
newDefId := idRemap[*item.VSDefinitionId]
|
|
item.VSDefinitionId = &newDefId
|
|
}
|
|
}
|
|
}
|
|
// Include file contents with markers
|
|
var locations []lsproto.Location
|
|
if result.VSReferenceItems != nil {
|
|
for _, item := range *result.VSReferenceItems {
|
|
locations = append(locations, item.VSLocation)
|
|
}
|
|
}
|
|
fileContents := f.getBaselineForLocationsWithFileContents(locations, baselineFourslashLocationsOptions{
|
|
marker: markerOrRange,
|
|
markerName: "/*FIND ALL REFS*/",
|
|
})
|
|
|
|
if jsonStr, err := core.StringifyJson(result, "", " "); err == nil {
|
|
f.addResultToBaseline(t, vsFindAllReferencesCmd, fileContents+"\n\n"+jsonStr)
|
|
} else {
|
|
t.Fatalf("Failed to stringify VS references result for baseline: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineCodeLens(t *testing.T, preferences *lsutil.UserPreferences) {
|
|
if preferences != nil {
|
|
reset := f.ConfigureWithReset(t, *preferences)
|
|
defer reset()
|
|
}
|
|
|
|
foundAtLeastOneCodeLens := false
|
|
for _, openFile := range slices.Sorted(maps.Keys(f.openFiles)) {
|
|
params := &lsproto.CodeLensParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(openFile),
|
|
},
|
|
}
|
|
|
|
unresolvedCodeLensList := sendRequest(t, f, lsproto.TextDocumentCodeLensInfo, params)
|
|
if unresolvedCodeLensList.CodeLenses == nil || len(*unresolvedCodeLensList.CodeLenses) == 0 {
|
|
continue
|
|
}
|
|
foundAtLeastOneCodeLens = true
|
|
|
|
for _, unresolvedCodeLens := range *unresolvedCodeLensList.CodeLenses {
|
|
assert.Assert(t, unresolvedCodeLens != nil)
|
|
resolvedCodeLens := sendRequest(t, f, lsproto.CodeLensResolveInfo, unresolvedCodeLens)
|
|
assert.Assert(t, resolvedCodeLens != nil)
|
|
assert.Assert(t, resolvedCodeLens.Command != nil, "Expected resolved code lens to have a command.")
|
|
if len(resolvedCodeLens.Command.Command) > 0 {
|
|
assert.Equal(t, resolvedCodeLens.Command.Command, showCodeLensLocationsCommandName)
|
|
}
|
|
|
|
var locations []lsproto.Location
|
|
// commandArgs: (DocumentUri, Position, Location[])
|
|
if commandArgs := resolvedCodeLens.Command.Arguments; commandArgs != nil {
|
|
locs, err := roundtripThroughJson[[]lsproto.Location]((*commandArgs)[2])
|
|
if err != nil {
|
|
t.Fatalf("failed to re-encode code lens locations: %v", err)
|
|
}
|
|
locations = locs
|
|
}
|
|
|
|
f.addResultToBaseline(t, codeLensesCmd, f.getBaselineForLocationsWithFileContents(locations, baselineFourslashLocationsOptions{
|
|
marker: &RangeMarker{
|
|
fileName: openFile,
|
|
LSRange: resolvedCodeLens.Range,
|
|
Range: f.converters.FromLSPRange(f.getScriptInfo(openFile), resolvedCodeLens.Range),
|
|
},
|
|
markerName: "/*CODELENS: " + resolvedCodeLens.Command.Title + "*/",
|
|
}))
|
|
}
|
|
}
|
|
|
|
if !foundAtLeastOneCodeLens {
|
|
t.Fatalf("Expected at least one code lens in any open file, but got none.")
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) MarkTestAsStradaServer() {
|
|
f.isStradaServer = true
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineGoToDefinition(
|
|
t *testing.T,
|
|
includeOriginalSelectionRange bool,
|
|
markers ...string,
|
|
) {
|
|
f.verifyBaselineDefinitions(
|
|
t,
|
|
goToDefinitionCmd,
|
|
"/*GOTO DEF*/", /*definitionMarker*/
|
|
func(t *testing.T, f *FourslashTest, fileName string, position lsproto.Position) lsproto.LocationOrLocationsOrDefinitionLinksOrNull {
|
|
params := &lsproto.DefinitionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
|
|
return sendRequest(t, f, lsproto.TextDocumentDefinitionInfo, params)
|
|
},
|
|
includeOriginalSelectionRange,
|
|
markers...,
|
|
)
|
|
}
|
|
|
|
func (f *FourslashTest) verifyBaselineDefinitions(
|
|
t *testing.T,
|
|
definitionCommand baselineCommand,
|
|
definitionMarker string,
|
|
getDefinitions func(t *testing.T, f *FourslashTest, fileName string, position lsproto.Position) lsproto.LocationOrLocationsOrDefinitionLinksOrNull,
|
|
includeOriginalSelectionRange bool,
|
|
markers ...string,
|
|
) {
|
|
referenceLocations := f.lookupMarkersOrGetRanges(t, markers)
|
|
|
|
for _, markerOrRange := range referenceLocations {
|
|
// worker in `baselineEachMarkerOrRange`
|
|
f.GoToMarkerOrRange(t, markerOrRange)
|
|
|
|
result := getDefinitions(t, f, f.activeFilename, f.currentCaretPosition)
|
|
|
|
var resultAsSpans []documentSpan
|
|
var additionalSpan *documentSpan
|
|
if result.Locations != nil {
|
|
resultAsSpans = core.Map(*result.Locations, locationToSpan)
|
|
} else if result.Location != nil {
|
|
resultAsSpans = []documentSpan{locationToSpan(*result.Location)}
|
|
} else if result.DefinitionLinks != nil {
|
|
var originRange *lsproto.Range
|
|
resultAsSpans = core.Map(*result.DefinitionLinks, func(link *lsproto.LocationLink) documentSpan {
|
|
if originRange != nil && (link.OriginSelectionRange == nil || *originRange != *link.OriginSelectionRange) {
|
|
panic("multiple different origin ranges in definition links")
|
|
}
|
|
originRange = link.OriginSelectionRange
|
|
var contextSpan *lsproto.Range
|
|
if link.TargetRange != link.TargetSelectionRange && !f.isStradaServer {
|
|
contextSpan = &link.TargetRange
|
|
}
|
|
return documentSpan{
|
|
uri: link.TargetUri,
|
|
textSpan: link.TargetSelectionRange,
|
|
contextSpan: contextSpan,
|
|
}
|
|
})
|
|
if originRange != nil && includeOriginalSelectionRange {
|
|
additionalSpan = &documentSpan{
|
|
uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
textSpan: *originRange,
|
|
}
|
|
}
|
|
}
|
|
|
|
f.addResultToBaseline(t, definitionCommand, f.getBaselineForSpansWithFileContents(resultAsSpans, baselineFourslashLocationsOptions{
|
|
marker: markerOrRange,
|
|
markerName: definitionMarker,
|
|
additionalSpan: additionalSpan,
|
|
preserveResultOrder: definitionCommand == goToSourceDefinitionCmd,
|
|
}))
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineGoToTypeDefinition(
|
|
t *testing.T,
|
|
markers ...string,
|
|
) {
|
|
f.verifyBaselineDefinitions(
|
|
t,
|
|
goToTypeDefinitionCmd,
|
|
"/*GOTO TYPE*/", /*definitionMarker*/
|
|
func(t *testing.T, f *FourslashTest, fileName string, position lsproto.Position) lsproto.LocationOrLocationsOrDefinitionLinksOrNull {
|
|
params := &lsproto.TypeDefinitionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
|
|
return sendRequest(t, f, lsproto.TextDocumentTypeDefinitionInfo, params)
|
|
},
|
|
false, /*includeOriginalSelectionRange*/
|
|
markers...,
|
|
)
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineGoToSourceDefinition(
|
|
t *testing.T,
|
|
markers ...string,
|
|
) {
|
|
f.verifyBaselineDefinitions(
|
|
t,
|
|
goToSourceDefinitionCmd,
|
|
"/*GOTO SOURCE DEF*/", /*definitionMarker*/
|
|
func(t *testing.T, f *FourslashTest, fileName string, position lsproto.Position) lsproto.LocationOrLocationsOrDefinitionLinksOrNull {
|
|
params := &lsproto.TextDocumentPositionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
|
|
result := sendRequest(t, f, lsproto.CustomTextDocumentSourceDefinitionInfo, params)
|
|
if result == nil {
|
|
return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{}
|
|
}
|
|
return *result
|
|
},
|
|
false, /*includeOriginalSelectionRange*/
|
|
markers...,
|
|
)
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineWorkspaceSymbol(t *testing.T, query string) {
|
|
t.Helper()
|
|
result := sendRequest(t, f, lsproto.WorkspaceSymbolInfo, &lsproto.WorkspaceSymbolParams{Query: query})
|
|
|
|
locationToText := map[documentSpan]*lsproto.SymbolInformation{}
|
|
groupedRanges := collections.MultiMap[lsproto.DocumentUri, documentSpan]{}
|
|
var symbolInformations []*lsproto.SymbolInformation
|
|
if result.SymbolInformations != nil {
|
|
symbolInformations = *result.SymbolInformations
|
|
}
|
|
for _, symbol := range symbolInformations {
|
|
uri := symbol.Location.Uri
|
|
span := locationToSpan(symbol.Location)
|
|
groupedRanges.Add(uri, span)
|
|
locationToText[span] = symbol
|
|
}
|
|
|
|
f.addResultToBaseline(t, "workspaceSymbol", f.getBaselineForGroupedSpansWithFileContents(
|
|
&groupedRanges,
|
|
baselineFourslashLocationsOptions{
|
|
getLocationData: func(span documentSpan) string { return symbolInformationToData(locationToText[span]) },
|
|
},
|
|
))
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyOutliningSpans(t *testing.T, foldingRangeKind ...lsproto.FoldingRangeKind) {
|
|
params := &lsproto.FoldingRangeParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentFoldingRangeInfo, params)
|
|
if result.FoldingRanges == nil {
|
|
t.Fatalf("Nil response received for folding range request")
|
|
}
|
|
|
|
// Extract actual folding ranges from the result and filter by kind if specified
|
|
var actualRanges []*lsproto.FoldingRange
|
|
actualRanges = *result.FoldingRanges
|
|
if len(foldingRangeKind) > 0 {
|
|
targetKind := foldingRangeKind[0]
|
|
var filtered []*lsproto.FoldingRange
|
|
for _, r := range actualRanges {
|
|
if r.Kind != nil && *r.Kind == targetKind {
|
|
filtered = append(filtered, r)
|
|
}
|
|
}
|
|
actualRanges = filtered
|
|
}
|
|
|
|
if len(actualRanges) != len(f.Ranges()) {
|
|
t.Fatalf("verifyOutliningSpans failed - expected total spans to be %d, but was %d",
|
|
len(f.Ranges()), len(actualRanges))
|
|
}
|
|
|
|
slices.SortFunc(f.Ranges(), func(a, b *RangeMarker) int {
|
|
return lsproto.ComparePositions(a.LSPos(), b.LSPos())
|
|
})
|
|
|
|
for i, expectedRange := range f.Ranges() {
|
|
actualRange := actualRanges[i]
|
|
startPos := lsproto.Position{Line: actualRange.StartLine, Character: *actualRange.StartCharacter}
|
|
endPos := lsproto.Position{Line: actualRange.EndLine, Character: *actualRange.EndCharacter}
|
|
|
|
if lsproto.ComparePositions(startPos, expectedRange.LSRange.Start) != 0 ||
|
|
lsproto.ComparePositions(endPos, expectedRange.LSRange.End) != 0 {
|
|
t.Fatalf("verifyOutliningSpans failed - span %d has invalid positions:\n actual: start (%d,%d), end (%d,%d)\n expected: start (%d,%d), end (%d,%d)",
|
|
i+1,
|
|
actualRange.StartLine, *actualRange.StartCharacter, actualRange.EndLine, *actualRange.EndCharacter,
|
|
expectedRange.LSRange.Start.Line, expectedRange.LSRange.Start.Character, expectedRange.LSRange.End.Line, expectedRange.LSRange.End.Character)
|
|
}
|
|
}
|
|
}
|
|
|
|
// FoldingRangeLineExpected represents expected start and end lines for a folding range.
|
|
type FoldingRangeLineExpected struct {
|
|
StartLine uint32
|
|
EndLine uint32
|
|
}
|
|
|
|
// VerifyFoldingRangeLines verifies folding ranges by comparing only start and end lines.
|
|
// This is useful for testing with lineFoldingOnly where character positions are ignored.
|
|
func (f *FourslashTest) VerifyFoldingRangeLines(t *testing.T, expected []FoldingRangeLineExpected) {
|
|
params := &lsproto.FoldingRangeParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentFoldingRangeInfo, params)
|
|
if result.FoldingRanges == nil {
|
|
t.Fatalf("Nil response received for folding range request")
|
|
}
|
|
|
|
actualRanges := *result.FoldingRanges
|
|
if len(actualRanges) != len(expected) {
|
|
t.Fatalf("verifyFoldingRangeLines failed - expected %d ranges, got %d", len(expected), len(actualRanges))
|
|
}
|
|
|
|
for i, exp := range expected {
|
|
got := actualRanges[i]
|
|
if got.StartLine != exp.StartLine || got.EndLine != exp.EndLine {
|
|
t.Errorf("verifyFoldingRangeLines failed - range %d: expected (startLine=%d, endLine=%d), got (startLine=%d, endLine=%d)",
|
|
i, exp.StartLine, exp.EndLine, got.StartLine, got.EndLine)
|
|
}
|
|
}
|
|
}
|
|
|
|
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: lsconv.FileNameToDocumentURI(marker.fileName),
|
|
},
|
|
Position: marker.LSPosition,
|
|
}
|
|
|
|
result := sendRequest(t, f, lsproto.TextDocumentHoverInfo, params)
|
|
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, quickInfoCmd, annotateContentWithTooltips(t, f, markersAndItems, "quickinfo", getRange, getTooltipLines))
|
|
if jsonStr, err := core.StringifyJson(markersAndItems, "", " "); err == nil {
|
|
f.writeToBaseline(quickInfoCmd, 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
|
|
}
|
|
|
|
type hoverWithVerbosity struct {
|
|
Hover *lsproto.Hover `json:"hover"`
|
|
VerbosityLevel int `json:"verbosityLevel"`
|
|
}
|
|
|
|
// hoverContentString extracts the text content from a hover response for comparison.
|
|
func hoverContentString(hover *lsproto.Hover) string {
|
|
if hover == nil {
|
|
return ""
|
|
}
|
|
if hover.Contents.MarkupContent != nil {
|
|
return hover.Contents.MarkupContent.Value
|
|
}
|
|
if hover.Contents.String != nil {
|
|
return *hover.Contents.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineHoverWithVerbosity(t *testing.T, verbosityLevels map[string][]int) {
|
|
var markersAndItems []markerAndItem[*hoverWithVerbosity]
|
|
for _, marker := range f.Markers() {
|
|
if marker.Name == nil {
|
|
continue
|
|
}
|
|
levels, ok := verbosityLevels[*marker.Name]
|
|
if !ok {
|
|
levels = []int{0}
|
|
}
|
|
for i, level := range levels {
|
|
var verbLevel *int32
|
|
if level > 0 {
|
|
verbLevel = new(int32(level))
|
|
}
|
|
params := &lsproto.HoverParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(marker.fileName),
|
|
},
|
|
Position: marker.LSPosition,
|
|
VerbosityLevel: verbLevel,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentHoverInfo, params)
|
|
item := &hoverWithVerbosity{
|
|
Hover: result.Hover,
|
|
VerbosityLevel: level,
|
|
}
|
|
// If the previous level said it can't expand further, verify the hover
|
|
// content is identical, meaning the flag was accurate.
|
|
if i > 0 && level > levels[i-1] {
|
|
prevItem := markersAndItems[len(markersAndItems)-1].Item
|
|
if prevItem != nil && prevItem.Hover != nil && !prevItem.Hover.CanIncreaseVerbosity {
|
|
prevContent := hoverContentString(prevItem.Hover)
|
|
curContent := hoverContentString(item.Hover)
|
|
if prevContent != curContent {
|
|
t.Errorf("At marker %q: verbosity level %d response differs from level %d, but level %d had canIncreaseVerbosity=false.\n level %d: %s\n level %d: %s",
|
|
*marker.Name, level, levels[i-1], levels[i-1], levels[i-1], prevContent, level, curContent)
|
|
}
|
|
}
|
|
}
|
|
markersAndItems = append(markersAndItems, markerAndItem[*hoverWithVerbosity]{Marker: marker, Item: item})
|
|
}
|
|
}
|
|
|
|
getRange := func(item *hoverWithVerbosity) *lsproto.Range {
|
|
if item == nil || item.Hover == nil || item.Hover.Range == nil {
|
|
return nil
|
|
}
|
|
return item.Hover.Range
|
|
}
|
|
|
|
getTooltipLines := func(item, _prev *hoverWithVerbosity) []string {
|
|
if item == nil || item.Hover == nil {
|
|
return nil
|
|
}
|
|
var result []string
|
|
|
|
if item.Hover.Contents.MarkupContent != nil {
|
|
result = strings.Split(item.Hover.Contents.MarkupContent.Value, "\n")
|
|
}
|
|
if item.Hover.Contents.String != nil {
|
|
result = strings.Split(*item.Hover.Contents.String, "\n")
|
|
}
|
|
if item.Hover.Contents.MarkedStringWithLanguage != nil {
|
|
result = appendLinesForMarkedStringWithLanguage(result, item.Hover.Contents.MarkedStringWithLanguage)
|
|
}
|
|
if item.Hover.Contents.MarkedStrings != nil {
|
|
for _, ms := range *item.Hover.Contents.MarkedStrings {
|
|
if ms.MarkedStringWithLanguage != nil {
|
|
result = appendLinesForMarkedStringWithLanguage(result, ms.MarkedStringWithLanguage)
|
|
} else {
|
|
result = append(result, *ms.String)
|
|
}
|
|
}
|
|
}
|
|
|
|
result = append(result, fmt.Sprintf("(verbosity level: %d)", item.VerbosityLevel))
|
|
|
|
return result
|
|
}
|
|
|
|
f.addResultToBaseline(t, quickInfoCmd, annotateContentWithTooltips(t, f, markersAndItems, "quickinfo", getRange, getTooltipLines))
|
|
if jsonStr, err := core.StringifyJson(markersAndItems, "", " "); err == nil {
|
|
f.writeToBaseline(quickInfoCmd, jsonStr)
|
|
} else {
|
|
t.Fatalf("Failed to stringify markers and items for baseline: %v", err)
|
|
}
|
|
}
|
|
|
|
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: lsconv.FileNameToDocumentURI(marker.FileName()),
|
|
},
|
|
Position: marker.LSPosition,
|
|
}
|
|
|
|
result := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params)
|
|
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 := ""
|
|
|
|
// Determine active parameter: per-signature takes precedence over top-level per LSP spec
|
|
// "If provided (or `null`), this is used in place of `SignatureHelp.activeParameter`."
|
|
var activeParamPtr *lsproto.UintegerOrNull
|
|
if sig.ActiveParameter != nil {
|
|
activeParamPtr = sig.ActiveParameter
|
|
} else {
|
|
activeParamPtr = item.ActiveParameter
|
|
}
|
|
|
|
// Show active parameter if specified, and the signature text.
|
|
if activeParamPtr != nil && activeParamPtr.Uinteger != nil && sig.Parameters != nil {
|
|
activeParamIndex := int(*activeParamPtr.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, signatureHelpCmd, annotateContentWithTooltips(t, f, markersAndItems, "signaturehelp", getRange, getTooltipLines))
|
|
if jsonStr, err := core.StringifyJson(markersAndItems, "", " "); err == nil {
|
|
f.writeToBaseline(signatureHelpCmd, jsonStr)
|
|
} else {
|
|
t.Fatalf("Failed to stringify markers and items for baseline: %v", err)
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineSelectionRanges(t *testing.T) {
|
|
markers := f.Markers()
|
|
var result strings.Builder
|
|
newLine := "\n"
|
|
|
|
for i, marker := range markers {
|
|
if i > 0 {
|
|
result.WriteString(newLine)
|
|
for range 80 {
|
|
result.WriteByte('=')
|
|
}
|
|
result.WriteString(newLine)
|
|
result.WriteString(newLine)
|
|
}
|
|
|
|
script := f.getScriptInfo(marker.FileName())
|
|
fileContent := script.content
|
|
|
|
// Add the marker position indicator
|
|
markerPos := marker.Position
|
|
baselineContent := fileContent[:markerPos] + "/**/" + fileContent[markerPos:] + newLine
|
|
result.WriteString(baselineContent)
|
|
|
|
// Get selection ranges at this marker
|
|
params := &lsproto.SelectionRangeParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(marker.FileName()),
|
|
},
|
|
Positions: []lsproto.Position{marker.LSPosition},
|
|
}
|
|
|
|
selectionRangeResult := sendRequest(t, f, lsproto.TextDocumentSelectionRangeInfo, params)
|
|
|
|
if selectionRangeResult.SelectionRanges == nil || len(*selectionRangeResult.SelectionRanges) == 0 {
|
|
result.WriteString("No selection ranges available\n")
|
|
continue
|
|
}
|
|
|
|
selectionRange := (*selectionRangeResult.SelectionRanges)[0]
|
|
|
|
// Add blank line after source code section
|
|
result.WriteString(newLine)
|
|
|
|
// Walk through the selection range chain
|
|
for selectionRange != nil {
|
|
start := int(f.converters.LineAndCharacterToPosition(script, selectionRange.Range.Start))
|
|
end := int(f.converters.LineAndCharacterToPosition(script, selectionRange.Range.End))
|
|
|
|
// Create a masked version of the file showing only this range
|
|
runes := []rune(fileContent)
|
|
masked := make([]rune, len(runes))
|
|
for i, ch := range runes {
|
|
if i >= start && i < end {
|
|
// Keep characters in the selection range
|
|
if ch == ' ' {
|
|
masked[i] = '•'
|
|
} else if ch == '\n' || ch == '\r' {
|
|
masked[i] = ch // Keep line breaks as-is, will add arrow later
|
|
} else {
|
|
masked[i] = ch
|
|
}
|
|
} else {
|
|
// Replace characters outside the range
|
|
if ch == '\n' || ch == '\r' {
|
|
masked[i] = ch
|
|
} else {
|
|
masked[i] = ' '
|
|
}
|
|
}
|
|
}
|
|
|
|
maskedStr := string(masked)
|
|
|
|
// Add line break arrows
|
|
maskedStr = strings.ReplaceAll(maskedStr, "\n", "↲\n")
|
|
maskedStr = strings.ReplaceAll(maskedStr, "\r", "↲\r")
|
|
|
|
// Remove blank lines
|
|
lines := strings.Split(maskedStr, "\n")
|
|
var nonBlankLines []string
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed != "" && trimmed != "↲" {
|
|
nonBlankLines = append(nonBlankLines, line)
|
|
}
|
|
}
|
|
maskedStr = strings.Join(nonBlankLines, "\n")
|
|
|
|
// Find leading and trailing width of non-whitespace characters
|
|
maskedRunes := []rune(maskedStr)
|
|
isRealCharacter := func(ch rune) bool {
|
|
return ch != '•' && ch != '↲' && !stringutil.IsWhiteSpaceLike(ch)
|
|
}
|
|
|
|
leadingWidth := -1
|
|
for i, ch := range maskedRunes {
|
|
if isRealCharacter(ch) {
|
|
leadingWidth = i
|
|
break
|
|
}
|
|
}
|
|
|
|
trailingWidth := -1
|
|
for j := len(maskedRunes) - 1; j >= 0; j-- {
|
|
if isRealCharacter(maskedRunes[j]) {
|
|
trailingWidth = j
|
|
break
|
|
}
|
|
}
|
|
|
|
if leadingWidth != -1 && trailingWidth != -1 && leadingWidth <= trailingWidth {
|
|
// Clean up middle section
|
|
prefix := string(maskedRunes[:leadingWidth])
|
|
middle := string(maskedRunes[leadingWidth : trailingWidth+1])
|
|
suffix := string(maskedRunes[trailingWidth+1:])
|
|
|
|
middle = strings.ReplaceAll(middle, "•", " ")
|
|
middle = strings.ReplaceAll(middle, "↲", "")
|
|
|
|
maskedStr = prefix + middle + suffix
|
|
}
|
|
|
|
// Add blank line before multi-line ranges
|
|
if strings.Contains(maskedStr, "\n") {
|
|
result.WriteString(newLine)
|
|
}
|
|
|
|
result.WriteString(maskedStr)
|
|
if !strings.HasSuffix(maskedStr, "\n") {
|
|
result.WriteString(newLine)
|
|
}
|
|
|
|
selectionRange = selectionRange.Parent
|
|
}
|
|
}
|
|
f.addResultToBaseline(t, smartSelectionCmd, strings.TrimSuffix(result.String(), "\n"))
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineCallHierarchy(t *testing.T) {
|
|
fileName := f.activeFilename
|
|
position := f.currentCaretPosition
|
|
|
|
params := &lsproto.CallHierarchyPrepareParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(fileName),
|
|
},
|
|
Position: position,
|
|
}
|
|
|
|
prepareResult := sendRequest(t, f, lsproto.TextDocumentPrepareCallHierarchyInfo, params)
|
|
if prepareResult.CallHierarchyItems == nil || len(*prepareResult.CallHierarchyItems) == 0 {
|
|
f.addResultToBaseline(t, callHierarchyCmd, "No call hierarchy items available")
|
|
return
|
|
}
|
|
|
|
var result strings.Builder
|
|
|
|
for _, callHierarchyItem := range *prepareResult.CallHierarchyItems {
|
|
seen := make(map[callHierarchyItemKey]bool)
|
|
itemFileName := callHierarchyItem.Uri.FileName()
|
|
script := f.getOrLoadScriptInfo(itemFileName)
|
|
formatCallHierarchyItem(t, f, script, &result, *callHierarchyItem, callHierarchyItemDirectionRoot, seen, "")
|
|
}
|
|
|
|
f.addResultToBaseline(t, callHierarchyCmd, strings.TrimSuffix(result.String(), "\n"))
|
|
}
|
|
|
|
type callHierarchyItemDirection int
|
|
|
|
const (
|
|
callHierarchyItemDirectionRoot callHierarchyItemDirection = iota
|
|
callHierarchyItemDirectionIncoming
|
|
callHierarchyItemDirectionOutgoing
|
|
)
|
|
|
|
type callHierarchyItemKey struct {
|
|
uri lsproto.DocumentUri
|
|
range_ lsproto.Range
|
|
direction callHierarchyItemDirection
|
|
}
|
|
|
|
func symbolKindToLowercase(kind lsproto.SymbolKind) string {
|
|
return strings.ToLower(kind.String())
|
|
}
|
|
|
|
func formatCallHierarchyItem(
|
|
t *testing.T,
|
|
f *FourslashTest,
|
|
file *scriptInfo,
|
|
result *strings.Builder,
|
|
callHierarchyItem lsproto.CallHierarchyItem,
|
|
direction callHierarchyItemDirection,
|
|
seen map[callHierarchyItemKey]bool,
|
|
prefix string,
|
|
) {
|
|
key := callHierarchyItemKey{
|
|
uri: callHierarchyItem.Uri,
|
|
range_: callHierarchyItem.Range,
|
|
direction: direction,
|
|
}
|
|
alreadySeen := seen[key]
|
|
seen[key] = true
|
|
|
|
type incomingCallResult struct {
|
|
skip bool
|
|
seen bool
|
|
values []*lsproto.CallHierarchyIncomingCall
|
|
}
|
|
type outgoingCallResult struct {
|
|
skip bool
|
|
seen bool
|
|
values []*lsproto.CallHierarchyOutgoingCall
|
|
}
|
|
|
|
var incomingCalls incomingCallResult
|
|
var outgoingCalls outgoingCallResult
|
|
|
|
if direction == callHierarchyItemDirectionOutgoing {
|
|
incomingCalls.skip = true
|
|
} else if alreadySeen {
|
|
incomingCalls.seen = true
|
|
} else {
|
|
incomingParams := &lsproto.CallHierarchyIncomingCallsParams{
|
|
Item: &callHierarchyItem,
|
|
}
|
|
incomingResult := sendRequest(t, f, lsproto.CallHierarchyIncomingCallsInfo, incomingParams)
|
|
if incomingResult.CallHierarchyIncomingCalls != nil {
|
|
incomingCalls.values = *incomingResult.CallHierarchyIncomingCalls
|
|
}
|
|
}
|
|
|
|
if direction == callHierarchyItemDirectionIncoming {
|
|
outgoingCalls.skip = true
|
|
} else if alreadySeen {
|
|
outgoingCalls.seen = true
|
|
} else {
|
|
outgoingParams := &lsproto.CallHierarchyOutgoingCallsParams{
|
|
Item: &callHierarchyItem,
|
|
}
|
|
outgoingResult := sendRequest(t, f, lsproto.CallHierarchyOutgoingCallsInfo, outgoingParams)
|
|
if outgoingResult.CallHierarchyOutgoingCalls != nil {
|
|
outgoingCalls.values = *outgoingResult.CallHierarchyOutgoingCalls
|
|
}
|
|
}
|
|
|
|
trailingPrefix := prefix
|
|
result.WriteString(fmt.Sprintf("%s╭ name: %s\n", prefix, callHierarchyItem.Name))
|
|
result.WriteString(fmt.Sprintf("%s├ kind: %s\n", prefix, symbolKindToLowercase(callHierarchyItem.Kind)))
|
|
if callHierarchyItem.Detail != nil && *callHierarchyItem.Detail != "" {
|
|
result.WriteString(fmt.Sprintf("%s├ containerName: %s\n", prefix, *callHierarchyItem.Detail))
|
|
}
|
|
result.WriteString(fmt.Sprintf("%s├ file: %s\n", prefix, callHierarchyItem.Uri.FileName()))
|
|
result.WriteString(prefix)
|
|
result.WriteString("├ span:\n")
|
|
formatCallHierarchyItemSpan(f, file, result, callHierarchyItem.Range, prefix+"│ ", prefix+"│ ")
|
|
result.WriteString(prefix)
|
|
result.WriteString("├ selectionSpan:\n")
|
|
formatCallHierarchyItemSpan(f, file, result, callHierarchyItem.SelectionRange, prefix+"│ ", prefix+"│ ")
|
|
|
|
// Handle incoming calls
|
|
if incomingCalls.seen {
|
|
if outgoingCalls.skip {
|
|
result.WriteString(trailingPrefix)
|
|
result.WriteString("╰ incoming: ...\n")
|
|
} else {
|
|
result.WriteString(prefix)
|
|
result.WriteString("├ incoming: ...\n")
|
|
}
|
|
} else if !incomingCalls.skip {
|
|
if len(incomingCalls.values) == 0 {
|
|
if outgoingCalls.skip {
|
|
result.WriteString(trailingPrefix)
|
|
result.WriteString("╰ incoming: none\n")
|
|
} else {
|
|
result.WriteString(prefix)
|
|
result.WriteString("├ incoming: none\n")
|
|
}
|
|
} else {
|
|
result.WriteString(prefix)
|
|
result.WriteString("├ incoming:\n")
|
|
for i, incomingCall := range incomingCalls.values {
|
|
fromFileName := incomingCall.From.Uri.FileName()
|
|
fromFile := f.getOrLoadScriptInfo(fromFileName)
|
|
result.WriteString(prefix)
|
|
result.WriteString("│ ╭ from:\n")
|
|
formatCallHierarchyItem(t, f, fromFile, result, *incomingCall.From, callHierarchyItemDirectionIncoming, seen, prefix+"│ │ ")
|
|
result.WriteString(prefix)
|
|
result.WriteString("│ ├ fromSpans:\n")
|
|
|
|
fromSpansTrailingPrefix := trailingPrefix + "╰ ╰ "
|
|
if i < len(incomingCalls.values)-1 {
|
|
fromSpansTrailingPrefix = prefix + "│ ╰ "
|
|
} else if !outgoingCalls.skip && (!outgoingCalls.seen || len(outgoingCalls.values) > 0) {
|
|
fromSpansTrailingPrefix = prefix + "│ ╰ "
|
|
}
|
|
formatCallHierarchyItemSpans(f, fromFile, result, incomingCall.FromRanges, prefix+"│ │ ", fromSpansTrailingPrefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle outgoing calls
|
|
if outgoingCalls.seen {
|
|
result.WriteString(trailingPrefix)
|
|
result.WriteString("╰ outgoing: ...\n")
|
|
} else if !outgoingCalls.skip {
|
|
if len(outgoingCalls.values) == 0 {
|
|
result.WriteString(trailingPrefix)
|
|
result.WriteString("╰ outgoing: none\n")
|
|
} else {
|
|
result.WriteString(prefix)
|
|
result.WriteString("├ outgoing:\n")
|
|
for i, outgoingCall := range outgoingCalls.values {
|
|
toFileName := outgoingCall.To.Uri.FileName()
|
|
toFile := f.getOrLoadScriptInfo(toFileName)
|
|
result.WriteString(prefix)
|
|
result.WriteString("│ ╭ to:\n")
|
|
formatCallHierarchyItem(t, f, toFile, result, *outgoingCall.To, callHierarchyItemDirectionOutgoing, seen, prefix+"│ │ ")
|
|
result.WriteString(prefix)
|
|
result.WriteString("│ ├ fromSpans:\n")
|
|
|
|
fromSpansTrailingPrefix := trailingPrefix + "╰ ╰ "
|
|
if i < len(outgoingCalls.values)-1 {
|
|
fromSpansTrailingPrefix = prefix + "│ ╰ "
|
|
}
|
|
formatCallHierarchyItemSpans(f, file, result, outgoingCall.FromRanges, prefix+"│ │ ", fromSpansTrailingPrefix)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func formatCallHierarchyItemSpan(
|
|
f *FourslashTest,
|
|
file *scriptInfo,
|
|
result *strings.Builder,
|
|
span lsproto.Range,
|
|
prefix string,
|
|
closingPrefix string,
|
|
) {
|
|
startLc := span.Start
|
|
endLc := span.End
|
|
startPos := f.converters.LineAndCharacterToPosition(file, span.Start)
|
|
endPos := f.converters.LineAndCharacterToPosition(file, span.End)
|
|
|
|
// Compute line starts for the file
|
|
lineStarts := computeLineStarts(file.content)
|
|
|
|
// Find the line boundaries - expand to full lines
|
|
contextStart := int(startPos)
|
|
contextEnd := int(endPos)
|
|
|
|
// Expand to start of first line
|
|
for contextStart > 0 && file.content[contextStart-1] != '\n' && file.content[contextStart-1] != '\r' {
|
|
contextStart--
|
|
}
|
|
|
|
// Expand to end of last line
|
|
for contextEnd < len(file.content) && file.content[contextEnd] != '\n' && file.content[contextEnd] != '\r' {
|
|
contextEnd++
|
|
}
|
|
|
|
// Get actual line and character positions for the context
|
|
contextStartLine := int(startLc.Line)
|
|
contextEndLine := int(endLc.Line)
|
|
|
|
// Calculate line number padding
|
|
lineNumWidth := len(strconv.Itoa(contextEndLine+1)) + 2
|
|
|
|
result.WriteString(fmt.Sprintf("%s╭ %s:%d:%d-%d:%d\n", prefix, file.fileName, startLc.Line+1, startLc.Character+1, endLc.Line+1, endLc.Character+1))
|
|
|
|
for lineNum := contextStartLine; lineNum <= contextEndLine; lineNum++ {
|
|
lineStart := lineStarts[lineNum]
|
|
lineEnd := len(file.content)
|
|
if lineNum+1 < len(lineStarts) {
|
|
lineEnd = lineStarts[lineNum+1]
|
|
}
|
|
|
|
// Get the line content, trimming trailing newlines
|
|
lineContent := file.content[lineStart:lineEnd]
|
|
lineContent = strings.TrimRight(lineContent, "\r\n")
|
|
|
|
// Format with line number
|
|
lineNumStr := fmt.Sprintf("%d:", lineNum+1)
|
|
paddedLineNum := strings.Repeat(" ", lineNumWidth-len(lineNumStr)-1) + lineNumStr
|
|
if lineContent == "" {
|
|
result.WriteString(fmt.Sprintf("%s│ %s\n", prefix, paddedLineNum))
|
|
} else {
|
|
result.WriteString(fmt.Sprintf("%s│ %s %s\n", prefix, paddedLineNum, lineContent))
|
|
}
|
|
|
|
// Add selection carets if this line contains part of the span
|
|
if lineNum >= int(startLc.Line) && lineNum <= int(endLc.Line) {
|
|
selStart := 0
|
|
selEnd := len(lineContent)
|
|
|
|
if lineNum == int(startLc.Line) {
|
|
selStart = int(startLc.Character)
|
|
}
|
|
if lineNum == int(endLc.Line) {
|
|
selEnd = int(endLc.Character)
|
|
}
|
|
|
|
// Don't show carets for empty selections
|
|
isEmpty := startLc.Line == endLc.Line && startLc.Character == endLc.Character
|
|
if isEmpty {
|
|
// For empty selections, show a single "<" character
|
|
padding := strings.Repeat(" ", lineNumWidth+selStart)
|
|
result.WriteString(fmt.Sprintf("%s│ %s<\n", prefix, padding))
|
|
} else {
|
|
// Calculate selection length (at least 1)
|
|
selLength := selEnd - selStart
|
|
selLength = max(selLength, 1) // Trim to actual content on the line
|
|
if lineNum < int(endLc.Line) {
|
|
// For lines before the last, trim to line content length
|
|
if selEnd > len(lineContent) {
|
|
selEnd = len(lineContent)
|
|
selLength = selEnd - selStart
|
|
}
|
|
}
|
|
|
|
padding := strings.Repeat(" ", lineNumWidth+selStart)
|
|
carets := strings.Repeat("^", selLength)
|
|
result.WriteString(fmt.Sprintf("%s│ %s%s\n", prefix, padding, carets))
|
|
}
|
|
}
|
|
}
|
|
|
|
result.WriteString(closingPrefix)
|
|
result.WriteString("╰\n")
|
|
}
|
|
|
|
func computeLineStarts(content string) []int {
|
|
lineStarts := []int{0}
|
|
for i, ch := range content {
|
|
if ch == '\n' {
|
|
lineStarts = append(lineStarts, i+1)
|
|
}
|
|
}
|
|
return lineStarts
|
|
}
|
|
|
|
func formatCallHierarchyItemSpans(
|
|
f *FourslashTest,
|
|
file *scriptInfo,
|
|
result *strings.Builder,
|
|
spans []lsproto.Range,
|
|
prefix string,
|
|
trailingPrefix string,
|
|
) {
|
|
for i, span := range spans {
|
|
closingPrefix := prefix
|
|
if i == len(spans)-1 {
|
|
closingPrefix = trailingPrefix
|
|
}
|
|
formatCallHierarchyItemSpan(f, file, result, span, prefix, closingPrefix)
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineDocumentHighlights(
|
|
t *testing.T,
|
|
preferences *lsutil.UserPreferences,
|
|
markerOrRangeOrNames ...MarkerOrRangeOrName,
|
|
) {
|
|
f.VerifyBaselineDocumentHighlightsWithOptions(t, preferences, nil /*filesToSearch*/, markerOrRangeOrNames...)
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineDocumentHighlightsWithOptions(
|
|
t *testing.T,
|
|
preferences *lsutil.UserPreferences,
|
|
filesToSearch []string,
|
|
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, filesToSearch, markerOrRanges)
|
|
}
|
|
|
|
func (f *FourslashTest) verifyBaselineDocumentHighlights(
|
|
t *testing.T,
|
|
preferences *lsutil.UserPreferences,
|
|
filesToSearch []string,
|
|
markerOrRanges []MarkerOrRange,
|
|
) {
|
|
for _, markerOrRange := range markerOrRanges {
|
|
f.goToMarker(t, markerOrRange)
|
|
|
|
var spans []lsproto.Location
|
|
var header string
|
|
|
|
if len(filesToSearch) > 0 {
|
|
// Multi-file: use the custom method.
|
|
var searchURIs []lsproto.DocumentUri
|
|
for _, file := range filesToSearch {
|
|
searchURIs = append(searchURIs, lsconv.FileNameToDocumentURI(file))
|
|
}
|
|
|
|
params := &lsproto.MultiDocumentHighlightParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
FilesToSearch: searchURIs,
|
|
}
|
|
result := sendRequest(t, f, lsproto.CustomTextDocumentMultiDocumentHighlightInfo, params)
|
|
multiHighlights := result.MultiDocumentHighlights
|
|
if multiHighlights == nil {
|
|
multiHighlights = &[]*lsproto.MultiDocumentHighlight{}
|
|
}
|
|
|
|
for _, mh := range *multiHighlights {
|
|
for _, h := range mh.Highlights {
|
|
spans = append(spans, lsproto.Location{
|
|
Uri: mh.Uri,
|
|
Range: h.Range,
|
|
})
|
|
}
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("// filesToSearch:\n")
|
|
for _, file := range filesToSearch {
|
|
fmt.Fprintf(&sb, "// %s\n", file)
|
|
}
|
|
sb.WriteString("\n")
|
|
header = sb.String()
|
|
} else {
|
|
// Single-file: use the standard LSP method.
|
|
params := &lsproto.DocumentHighlightParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentDocumentHighlightInfo, params)
|
|
highlights := result.DocumentHighlights
|
|
if highlights == nil {
|
|
highlights = &[]*lsproto.DocumentHighlight{}
|
|
}
|
|
|
|
for _, h := range *highlights {
|
|
spans = append(spans, lsproto.Location{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
Range: h.Range,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add result to baseline
|
|
f.addResultToBaseline(t, documentHighlightsCmd, header+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
|
|
}
|
|
|
|
// This function is intended for spots where a complex
|
|
// value needs to be reinterpreted following some prior JSON deserialization.
|
|
// The default deserializer for `any` properties will give us a map at runtime,
|
|
// but we want to validate against, and use, the types as returned from the the language service.
|
|
//
|
|
// Use this function sparingly. You can treat it as a "map-to-struct" converter,
|
|
// but updating the original types is probably better in most cases.
|
|
func roundtripThroughJson[T any](value any) (T, error) {
|
|
var result T
|
|
bytes, err := json.Marshal(value)
|
|
if err != nil {
|
|
return result, fmt.Errorf("failed to marshal value to JSON: %w", err)
|
|
}
|
|
if err := json.Unmarshal(bytes, &result); err != nil {
|
|
return result, fmt.Errorf("failed to unmarshal value from JSON: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Insert text at the current caret position.
|
|
func (f *FourslashTest) Insert(t *testing.T, text string) {
|
|
t.Helper()
|
|
f.baselineState(t)
|
|
f.typeText(t, text)
|
|
}
|
|
|
|
// Insert text and a new line at the current caret position.
|
|
func (f *FourslashTest) InsertLine(t *testing.T, text string) {
|
|
t.Helper()
|
|
f.baselineState(t)
|
|
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))
|
|
f.baselineState(t)
|
|
|
|
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?
|
|
}
|
|
|
|
// DeleteAtCaret removes the text at the current caret position as if the user pressed delete `count` times.
|
|
func (f *FourslashTest) DeleteAtCaret(t *testing.T, count int) {
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
offset := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition))
|
|
f.baselineState(t)
|
|
|
|
for range count {
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, offset, offset+1, "")
|
|
// Position stays the same after delete (unlike backspace)
|
|
}
|
|
}
|
|
|
|
// 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.baselineState(t)
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, start, start, text)
|
|
|
|
// post-paste fomatting
|
|
if f.stateEnableFormatting {
|
|
result := sendRequestAndBaselineWorker(t, f, lsproto.TextDocumentRangeFormattingInfo, &lsproto.DocumentRangeFormattingParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Range: lsproto.Range{
|
|
Start: f.currentCaretPosition,
|
|
End: f.converters.PositionToLineAndCharacter(script, core.TextPos(start+len(text))),
|
|
},
|
|
Options: f.userPreferences.FormatCodeSettings.ToLSFormatOptions(),
|
|
}, false)
|
|
if result.TextEdits != nil {
|
|
f.applyTextEdits(t, *result.TextEdits)
|
|
}
|
|
}
|
|
// 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.baselineState(t)
|
|
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]
|
|
var end core.TextPos
|
|
if lineIndex+1 >= len(script.lineMap.LineStarts) {
|
|
end = core.TextPos(len(script.content))
|
|
} else {
|
|
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)),
|
|
)
|
|
}
|
|
|
|
// Updates f.currentCaretPosition
|
|
func (f *FourslashTest) applyTextEdits(t *testing.T, edits []*lsproto.TextEdit) int {
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
slices.SortFunc(edits, func(a, b *lsproto.TextEdit) int {
|
|
aStart := f.converters.LineAndCharacterToPosition(script, a.Range.Start)
|
|
bStart := f.converters.LineAndCharacterToPosition(script, b.Range.Start)
|
|
return int(aStart) - int(bStart)
|
|
})
|
|
|
|
totalOffset := 0
|
|
currentCaretPosition := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition))
|
|
// Apply edits in reverse order to avoid affecting the positions of earlier edits.
|
|
for i := len(edits) - 1; i >= 0; i-- {
|
|
edit := edits[i]
|
|
start := int(f.converters.LineAndCharacterToPosition(script, edit.Range.Start))
|
|
end := int(f.converters.LineAndCharacterToPosition(script, edit.Range.End))
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, start, end, edit.NewText)
|
|
|
|
delta := len(edit.NewText) - (end - start)
|
|
if start <= currentCaretPosition {
|
|
if end <= currentCaretPosition {
|
|
// The entirety of the edit span falls before the caret position, shift the caret accordingly
|
|
currentCaretPosition += delta
|
|
} else {
|
|
// The span being replaced includes the caret position, place the caret at the beginning of the span
|
|
currentCaretPosition = start
|
|
}
|
|
}
|
|
totalOffset += delta
|
|
}
|
|
f.currentCaretPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(currentCaretPosition))
|
|
return totalOffset
|
|
}
|
|
|
|
func (f *FourslashTest) Replace(t *testing.T, start int, length int, text string) {
|
|
f.baselineState(t)
|
|
f.replaceWorker(t, start, length, text)
|
|
}
|
|
|
|
func (f *FourslashTest) replaceWorker(t *testing.T, start int, length int, text string) {
|
|
t.Helper()
|
|
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) {
|
|
// temprorary -- this disables tests failing if format crashes; this unblocks unrelated tests such as codefixes
|
|
f.reportFormatOnTypeCrash = false
|
|
defer func() {
|
|
f.reportFormatOnTypeCrash = true
|
|
}()
|
|
|
|
script := f.getScriptInfo(f.activeFilename)
|
|
selection := f.getSelection()
|
|
f.replaceWorker(t, selection.Pos(), selection.End()-selection.Pos(), "")
|
|
|
|
totalSize := 0
|
|
|
|
offset := int(f.converters.LineAndCharacterToPosition(script, f.currentCaretPosition))
|
|
for totalSize < len(text) {
|
|
r, size := utf8.DecodeRuneInString(text[totalSize:])
|
|
f.editScriptAndUpdateMarkers(t, f.activeFilename, offset, offset, string(r))
|
|
|
|
totalSize += size
|
|
offset += size
|
|
f.currentCaretPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(offset))
|
|
|
|
// Handle post-keystroke formatting
|
|
if f.stateEnableFormatting {
|
|
result := sendRequestAndBaselineWorker(t, f, lsproto.TextDocumentOnTypeFormattingInfo, &lsproto.DocumentOnTypeFormattingParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Ch: string(r),
|
|
Options: f.userPreferences.FormatCodeSettings.ToLSFormatOptions(),
|
|
}, false)
|
|
if result.TextEdits != nil {
|
|
offset += f.applyTextEdits(t, *result.TextEdits)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
f.editScriptAndUpdateMarkersWorker(t, fileName, []core.TextChange{{TextRange: core.NewTextRange(editStart, editEnd), NewText: newText}})
|
|
}
|
|
|
|
func (f *FourslashTest) editScriptAndUpdateMarkersWorker(t *testing.T, fileName string, changes []core.TextChange) {
|
|
// Sort changes by position (ascending) so we can apply in reverse
|
|
sortedChanges := slices.Clone(changes)
|
|
slices.SortFunc(sortedChanges, func(a, b core.TextChange) int {
|
|
return a.Pos() - b.Pos()
|
|
})
|
|
|
|
// Apply changes in reverse order to preserve positions of earlier changes
|
|
for i := len(sortedChanges) - 1; i >= 0; i-- {
|
|
change := sortedChanges[i]
|
|
editStart := change.Pos()
|
|
editEnd := change.End()
|
|
script := f.editScript(t, fileName, change)
|
|
for _, marker := range f.testData.Markers {
|
|
if marker.FileName() == fileName {
|
|
marker.Position = updatePosition(marker.Position, editStart, editEnd, change.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, change.NewText)
|
|
end := updatePosition(rangeMarker.Range.End(), editStart, editEnd, change.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, change core.TextChange) *scriptInfo {
|
|
script := f.getOrLoadScriptInfo(fileName)
|
|
if script == nil {
|
|
panic(fmt.Sprintf("Script info for file %s not found", fileName))
|
|
}
|
|
|
|
changeRange := f.converters.ToLSPRange(script, core.NewTextRange(change.Pos(), change.End()))
|
|
script.editContent(change)
|
|
if err := f.vfs.WriteFile(fileName, script.content); err != nil {
|
|
t.Fatalf("failed to write to VFS for %s: %v", fileName, err)
|
|
}
|
|
sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{
|
|
TextDocument: lsproto.VersionedTextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(fileName),
|
|
Version: script.version,
|
|
},
|
|
ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{
|
|
Partial: &lsproto.TextDocumentContentChangePartial{
|
|
Range: changeRange,
|
|
Text: change.NewText,
|
|
},
|
|
}},
|
|
})
|
|
return script
|
|
}
|
|
|
|
func (f *FourslashTest) getScriptInfo(fileName string) *scriptInfo {
|
|
return f.scriptInfos[fileName]
|
|
}
|
|
|
|
func (f *FourslashTest) getOrLoadScriptInfo(fileName string) *scriptInfo {
|
|
if script := f.getScriptInfo(fileName); script != nil {
|
|
return script
|
|
}
|
|
if content, ok := f.vfs.ReadFile(fileName); ok {
|
|
script := newScriptInfo(fileName, content)
|
|
f.scriptInfos[fileName] = script
|
|
return script
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// !!! 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: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentHoverInfo, params)
|
|
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("```typescript\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())
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyJsxClosingTag(t *testing.T, markersToNewText map[string]*string) {
|
|
for marker, expectedText := range markersToNewText {
|
|
f.GoToMarker(t, marker)
|
|
params := &lsproto.VSOnAutoInsertParams{
|
|
VSTextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
VSPosition: f.currentCaretPosition,
|
|
VSCh: ">",
|
|
}
|
|
|
|
requestResult := sendRequest(t, f, lsproto.TextDocumentVSOnAutoInsertInfo, params)
|
|
|
|
var actualText *string
|
|
if item := requestResult.VSOnAutoInsertResponseItem; item != nil && item.VSTextEdit != nil {
|
|
newText := item.VSTextEdit.NewText
|
|
if item.VSTextEditFormat == lsproto.InsertTextFormatSnippet {
|
|
var ok bool
|
|
newText, ok = strings.CutPrefix(newText, "$0")
|
|
if !ok {
|
|
t.Fatalf("%sexpected JSX closing tag snippet to begin with $0, got %q", f.getCurrentPositionPrefix(), item.VSTextEdit.NewText)
|
|
}
|
|
}
|
|
actualText = &newText
|
|
}
|
|
assertDeepEqual(t, actualText, expectedText, f.getCurrentPositionPrefix()+"JSX closing tag text mismatch")
|
|
}
|
|
}
|
|
|
|
// VerifyBaselineClosingTags generates a baseline for JSX closing tag completions at all markers.
|
|
func (f *FourslashTest) VerifyBaselineClosingTags(t *testing.T) {
|
|
t.Helper()
|
|
|
|
markersAndItems := core.MapFiltered(f.Markers(), func(marker *Marker) (markerAndItem[*lsproto.VSOnAutoInsertResponseItem], bool) {
|
|
if marker.Name == nil {
|
|
return markerAndItem[*lsproto.VSOnAutoInsertResponseItem]{}, false
|
|
}
|
|
|
|
params := &lsproto.VSOnAutoInsertParams{
|
|
VSTextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(marker.FileName()),
|
|
},
|
|
VSPosition: marker.LSPosition,
|
|
VSCh: ">",
|
|
}
|
|
|
|
result := sendRequest(t, f, lsproto.TextDocumentVSOnAutoInsertInfo, params)
|
|
return markerAndItem[*lsproto.VSOnAutoInsertResponseItem]{Marker: marker, Item: result.VSOnAutoInsertResponseItem}, true
|
|
})
|
|
|
|
getRange := func(item *lsproto.VSOnAutoInsertResponseItem) *lsproto.Range {
|
|
// Returning nil lets annotateContentWithTooltips render the caret marker at
|
|
// the marker position. The text edit's range is zero-width at the cursor,
|
|
// which would render as an empty underline.
|
|
return nil
|
|
}
|
|
|
|
getTooltipLines := func(item, _prev *lsproto.VSOnAutoInsertResponseItem) []string {
|
|
if item == nil || item.VSTextEdit == nil {
|
|
return []string{"No closing tag"}
|
|
}
|
|
format := "plaintext"
|
|
if item.VSTextEditFormat == lsproto.InsertTextFormatSnippet {
|
|
format = "snippet"
|
|
}
|
|
return []string{fmt.Sprintf("%s: %q", format, item.VSTextEdit.NewText)}
|
|
}
|
|
|
|
result := annotateContentWithTooltips(t, f, markersAndItems, "closing tag", getRange, getTooltipLines)
|
|
f.addResultToBaseline(t, closingTagCmd, result)
|
|
}
|
|
|
|
// VerifySignatureHelpOptions contains options for verifying signature help.
|
|
// All fields are optional - only specified fields will be verified.
|
|
type VerifySignatureHelpOptions struct {
|
|
// Text is the full signature text (e.g., "fn(x: string, y: number): void")
|
|
Text string
|
|
// DocComment is the documentation comment for the signature
|
|
DocComment string
|
|
// ParameterCount is the expected number of parameters
|
|
ParameterCount int
|
|
// ParameterName is the expected name of the active parameter
|
|
ParameterName string
|
|
// ParameterSpan is the expected label of the active parameter (e.g., "x: string")
|
|
ParameterSpan string
|
|
// ParameterDocComment is the documentation for the active parameter
|
|
ParameterDocComment string
|
|
// OverloadsCount is the expected number of overloads (signatures)
|
|
OverloadsCount int
|
|
// OverrideSelectedItemIndex overrides which signature to check (default: ActiveSignature)
|
|
OverrideSelectedItemIndex int
|
|
// IsVariadic indicates if the signature has a rest parameter
|
|
IsVariadic bool
|
|
// IsVariadicSet is true when IsVariadic was explicitly set (to distinguish from default false)
|
|
IsVariadicSet bool
|
|
}
|
|
|
|
// VerifySignatureHelp verifies signature help at the current position matches the expected options.
|
|
func (f *FourslashTest) VerifySignatureHelp(t *testing.T, expected VerifySignatureHelpOptions) {
|
|
t.Helper()
|
|
prefix := f.getCurrentPositionPrefix()
|
|
params := &lsproto.SignatureHelpParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params)
|
|
help := result.SignatureHelp
|
|
if help == nil {
|
|
t.Fatalf("%sCould not get signature help", prefix)
|
|
}
|
|
|
|
// Determine which signature to check
|
|
selectedIndex := 0
|
|
if expected.OverrideSelectedItemIndex > 0 {
|
|
selectedIndex = expected.OverrideSelectedItemIndex
|
|
} else if help.ActiveSignature != nil {
|
|
selectedIndex = int(*help.ActiveSignature)
|
|
}
|
|
|
|
if selectedIndex >= len(help.Signatures) {
|
|
t.Fatalf("%sSelected signature index %d out of range (have %d signatures)", prefix, selectedIndex, len(help.Signatures))
|
|
}
|
|
|
|
selectedSig := help.Signatures[selectedIndex]
|
|
|
|
// Verify overloads count
|
|
if expected.OverloadsCount > 0 {
|
|
if len(help.Signatures) != expected.OverloadsCount {
|
|
t.Errorf("%sExpected %d overloads, got %d", prefix, expected.OverloadsCount, len(help.Signatures))
|
|
}
|
|
}
|
|
|
|
// Verify signature text
|
|
if expected.Text != "" {
|
|
if selectedSig.Label != expected.Text {
|
|
t.Errorf("%sExpected signature text %q, got %q", prefix, expected.Text, selectedSig.Label)
|
|
}
|
|
}
|
|
|
|
// Verify doc comment
|
|
if expected.DocComment != "" {
|
|
actualDoc := ""
|
|
if selectedSig.Documentation != nil {
|
|
if selectedSig.Documentation.MarkupContent != nil {
|
|
actualDoc = selectedSig.Documentation.MarkupContent.Value
|
|
} else if selectedSig.Documentation.String != nil {
|
|
actualDoc = *selectedSig.Documentation.String
|
|
}
|
|
}
|
|
if actualDoc != expected.DocComment {
|
|
t.Errorf("%sExpected doc comment %q, got %q", prefix, expected.DocComment, actualDoc)
|
|
}
|
|
}
|
|
|
|
// Verify parameter count
|
|
if expected.ParameterCount > 0 {
|
|
paramCount := 0
|
|
if selectedSig.Parameters != nil {
|
|
paramCount = len(*selectedSig.Parameters)
|
|
}
|
|
if paramCount != expected.ParameterCount {
|
|
t.Errorf("%sExpected %d parameters, got %d", prefix, expected.ParameterCount, paramCount)
|
|
}
|
|
}
|
|
|
|
// Get active parameter
|
|
var activeParamIndex int
|
|
if selectedSig.ActiveParameter != nil && selectedSig.ActiveParameter.Uinteger != nil {
|
|
activeParamIndex = int(*selectedSig.ActiveParameter.Uinteger)
|
|
} else if help.ActiveParameter != nil && help.ActiveParameter.Uinteger != nil {
|
|
activeParamIndex = int(*help.ActiveParameter.Uinteger)
|
|
}
|
|
|
|
var activeParam *lsproto.ParameterInformation
|
|
if selectedSig.Parameters != nil && activeParamIndex < len(*selectedSig.Parameters) {
|
|
activeParam = (*selectedSig.Parameters)[activeParamIndex]
|
|
}
|
|
|
|
// Verify parameter name
|
|
if expected.ParameterName != "" {
|
|
if activeParam == nil {
|
|
t.Errorf("%sExpected parameter name %q, but no active parameter", prefix, expected.ParameterName)
|
|
} else {
|
|
// Parameter name is extracted from the label
|
|
actualName := ""
|
|
if activeParam.Label.String != nil {
|
|
// Extract name from label like "x: string" -> "x" or "T extends Foo" -> "T" or "...x: any[]" -> "x"
|
|
label := *activeParam.Label.String
|
|
// Strip leading "..." for rest parameters
|
|
label = strings.TrimPrefix(label, "...")
|
|
if name, _, found := strings.Cut(label, ":"); found {
|
|
actualName = strings.TrimSpace(name)
|
|
} else if name, _, found := strings.Cut(label, " extends "); found {
|
|
actualName = strings.TrimSpace(name)
|
|
} else {
|
|
actualName = label
|
|
}
|
|
}
|
|
if actualName != expected.ParameterName {
|
|
t.Errorf("%sExpected parameter name %q, got %q", prefix, expected.ParameterName, actualName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify parameter span (label)
|
|
if expected.ParameterSpan != "" {
|
|
if activeParam == nil {
|
|
t.Errorf("%sExpected parameter span %q, but no active parameter", prefix, expected.ParameterSpan)
|
|
} else {
|
|
actualSpan := ""
|
|
if activeParam.Label.String != nil {
|
|
actualSpan = *activeParam.Label.String
|
|
}
|
|
if actualSpan != expected.ParameterSpan {
|
|
t.Errorf("%sExpected parameter span %q, got %q", prefix, expected.ParameterSpan, actualSpan)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify parameter doc comment
|
|
if expected.ParameterDocComment != "" {
|
|
if activeParam == nil {
|
|
t.Errorf("%sExpected parameter doc comment %q, but no active parameter", prefix, expected.ParameterDocComment)
|
|
} else {
|
|
actualDoc := ""
|
|
if activeParam.Documentation != nil {
|
|
if activeParam.Documentation.MarkupContent != nil {
|
|
actualDoc = activeParam.Documentation.MarkupContent.Value
|
|
} else if activeParam.Documentation.String != nil {
|
|
actualDoc = *activeParam.Documentation.String
|
|
}
|
|
}
|
|
if actualDoc != expected.ParameterDocComment {
|
|
t.Errorf("%sExpected parameter doc comment %q, got %q", prefix, expected.ParameterDocComment, actualDoc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify isVariadic (check if any parameter starts with "...")
|
|
if expected.IsVariadicSet {
|
|
actualIsVariadic := false
|
|
if selectedSig.Parameters != nil {
|
|
for _, param := range *selectedSig.Parameters {
|
|
if param.Label.String != nil && strings.HasPrefix(*param.Label.String, "...") {
|
|
actualIsVariadic = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if actualIsVariadic != expected.IsVariadic {
|
|
t.Errorf("%sExpected isVariadic=%v, got %v", prefix, expected.IsVariadic, actualIsVariadic)
|
|
}
|
|
}
|
|
}
|
|
|
|
// VerifyNoSignatureHelp verifies that no signature help is available at the current position.
|
|
func (f *FourslashTest) VerifyNoSignatureHelp(t *testing.T) {
|
|
t.Helper()
|
|
prefix := f.getCurrentPositionPrefix()
|
|
params := &lsproto.SignatureHelpParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params)
|
|
if result.SignatureHelp != nil && len(result.SignatureHelp.Signatures) > 0 {
|
|
t.Errorf("%sExpected no signature help, but got %d signatures", prefix, len(result.SignatureHelp.Signatures))
|
|
}
|
|
}
|
|
|
|
// VerifyNoSignatureHelpWithContext verifies that no signature help is available at the current position with a given context.
|
|
func (f *FourslashTest) VerifyNoSignatureHelpWithContext(t *testing.T, context *lsproto.SignatureHelpContext) {
|
|
t.Helper()
|
|
prefix := f.getCurrentPositionPrefix()
|
|
params := &lsproto.SignatureHelpParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: context,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params)
|
|
if result.SignatureHelp != nil && len(result.SignatureHelp.Signatures) > 0 {
|
|
t.Errorf("%sExpected no signature help, but got %d signatures", prefix, len(result.SignatureHelp.Signatures))
|
|
}
|
|
}
|
|
|
|
// VerifyNoSignatureHelpForMarkersWithContext verifies that no signature help is available at the given markers with a given context.
|
|
func (f *FourslashTest) VerifyNoSignatureHelpForMarkersWithContext(t *testing.T, context *lsproto.SignatureHelpContext, markers ...string) {
|
|
t.Helper()
|
|
for _, marker := range markers {
|
|
f.GoToMarker(t, marker)
|
|
f.VerifyNoSignatureHelpWithContext(t, context)
|
|
}
|
|
}
|
|
|
|
// VerifySignatureHelpPresent verifies that signature help is available at the current position with a given context.
|
|
func (f *FourslashTest) VerifySignatureHelpPresent(t *testing.T, context *lsproto.SignatureHelpContext) {
|
|
t.Helper()
|
|
prefix := f.getCurrentPositionPrefix()
|
|
params := &lsproto.SignatureHelpParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: context,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params)
|
|
if result.SignatureHelp == nil || len(result.SignatureHelp.Signatures) == 0 {
|
|
t.Errorf("%sExpected signature help to be present, but got none", prefix)
|
|
}
|
|
}
|
|
|
|
// VerifySignatureHelpPresentForMarkers verifies that signature help is available at the given markers with a given context.
|
|
func (f *FourslashTest) VerifySignatureHelpPresentForMarkers(t *testing.T, context *lsproto.SignatureHelpContext, markers ...string) {
|
|
t.Helper()
|
|
for _, marker := range markers {
|
|
f.GoToMarker(t, marker)
|
|
f.VerifySignatureHelpPresent(t, context)
|
|
}
|
|
}
|
|
|
|
// VerifyNoSignatureHelpForMarkers verifies that no signature help is available at the given markers.
|
|
func (f *FourslashTest) VerifyNoSignatureHelpForMarkers(t *testing.T, markers ...string) {
|
|
t.Helper()
|
|
for _, marker := range markers {
|
|
f.GoToMarker(t, marker)
|
|
f.VerifyNoSignatureHelp(t)
|
|
}
|
|
}
|
|
|
|
type SignatureHelpCase struct {
|
|
Context *lsproto.SignatureHelpContext
|
|
MarkerInput MarkerInput
|
|
Expected *lsproto.SignatureHelp
|
|
}
|
|
|
|
// VerifySignatureHelpWithCases verifies signature help using detailed SignatureHelpCase structs.
|
|
// This is useful for more complex tests that need to verify the full signature help response.
|
|
func (f *FourslashTest) VerifySignatureHelpWithCases(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: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: context,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentSignatureHelpInfo, params)
|
|
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 %s(Ln %d, Col %d): ", f.activeFilename, f.currentCaretPosition.Line, f.currentCaretPosition.Character)
|
|
}
|
|
|
|
func (f *FourslashTest) BaselineAutoImportsCompletions(t *testing.T, markerNames []string) {
|
|
t.Helper()
|
|
reset := f.ConfigureWithReset(t, lsutil.UserPreferences{
|
|
IncludeCompletionsForModuleExports: core.TSTrue,
|
|
IncludeCompletionsForImportStatements: core.TSTrue,
|
|
ImportModuleSpecifierPreference: f.userPreferences.ImportModuleSpecifierPreference,
|
|
ImportModuleSpecifierEnding: f.userPreferences.ImportModuleSpecifierEnding,
|
|
AutoImportSpecifierExcludeRegexes: f.userPreferences.AutoImportSpecifierExcludeRegexes,
|
|
AutoImportFileExcludePatterns: f.userPreferences.AutoImportFileExcludePatterns,
|
|
PreferTypeOnlyAutoImports: f.userPreferences.PreferTypeOnlyAutoImports,
|
|
AutoImportEntrypointDirectorySearch: f.userPreferences.AutoImportEntrypointDirectorySearch,
|
|
})
|
|
defer reset()
|
|
|
|
for _, markerName := range markerNames {
|
|
f.GoToMarker(t, markerName)
|
|
params := &lsproto.CompletionParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
Context: &lsproto.CompletionContext{},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentCompletionInfo, params)
|
|
|
|
prefix := fmt.Sprintf("At marker '%s': ", markerName)
|
|
|
|
f.writeToBaseline(autoImportsCmd, "// === Auto Imports === \n")
|
|
|
|
fileContent, ok := f.textOfFile(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(autoImportsCmd, codeFence(
|
|
lang,
|
|
"// @FileName: "+f.activeFilename+"\n"+fileContent[:marker.Position]+"/*"+markerName+"*/"+fileContent[marker.Position:],
|
|
))
|
|
|
|
currentFile := newScriptInfo(f.activeFilename, fileContent)
|
|
converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.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(autoImportsCmd, "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
|
|
}
|
|
details := sendRequest(t, f, lsproto.CompletionItemResolveInfo, item)
|
|
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 lsproto.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(autoImportsCmd, codeFence(lang, newFileContent)+"\n\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// string | *Marker | *RangeMarker
|
|
type MarkerOrRangeOrName = any
|
|
|
|
func (f *FourslashTest) VerifyBaselineRename(
|
|
t *testing.T,
|
|
preferences *lsutil.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 *lsutil.UserPreferences,
|
|
markerOrRanges []MarkerOrRange,
|
|
) {
|
|
if preferences != nil {
|
|
defer f.ConfigureWithReset(t, *preferences)()
|
|
}
|
|
|
|
for _, markerOrRange := range markerOrRanges {
|
|
f.GoToMarkerOrRange(t, markerOrRange)
|
|
|
|
params := &lsproto.RenameParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
NewName: "?",
|
|
}
|
|
|
|
result := sendRequest(t, f, lsproto.TextDocumentRenameInfo, params)
|
|
|
|
var changes map[lsproto.DocumentUri][]*lsproto.TextEdit
|
|
if result.WorkspaceEdit != nil && result.WorkspaceEdit.Changes != nil {
|
|
changes = *result.WorkspaceEdit.Changes
|
|
}
|
|
spanToText := map[documentSpan]string{}
|
|
fileToSpan := collections.MultiMap[lsproto.DocumentUri, documentSpan]{}
|
|
for uri, edits := range changes {
|
|
for _, edit := range edits {
|
|
span := documentSpan{uri: uri, textSpan: edit.Range}
|
|
fileToSpan.Add(uri, span)
|
|
spanToText[span] = 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 != lsutil.QuotePreferenceUnknown {
|
|
fmt.Fprintf(&renameOptions, "// @quotePreference: %v\n", preferences.QuotePreference)
|
|
}
|
|
}
|
|
|
|
baselineFileContent := f.getBaselineForGroupedSpansWithFileContents(
|
|
&fileToSpan,
|
|
baselineFourslashLocationsOptions{
|
|
marker: markerOrRange,
|
|
markerName: "/*RENAME*/",
|
|
endMarker: "RENAME|]",
|
|
startMarkerPrefix: func(span documentSpan) *string {
|
|
text := spanToText[span]
|
|
prefixAndSuffix := strings.Split(text, "?")
|
|
if prefixAndSuffix[0] != "" {
|
|
return new("/*START PREFIX*/" + prefixAndSuffix[0])
|
|
}
|
|
return nil
|
|
},
|
|
endMarkerSuffix: func(span documentSpan) *string {
|
|
text := spanToText[span]
|
|
prefixAndSuffix := strings.Split(text, "?")
|
|
if prefixAndSuffix[1] != "" {
|
|
return new(prefixAndSuffix[1] + "/*END SUFFIX*/")
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
)
|
|
|
|
var baselineResult string
|
|
if renameOptions.Len() > 0 {
|
|
baselineResult = renameOptions.String() + "\n" + baselineFileContent
|
|
} else {
|
|
baselineResult = baselineFileContent
|
|
}
|
|
|
|
f.addResultToBaseline(t, renameCmd, baselineResult)
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyRenameSucceeded(t *testing.T, preferences *lsutil.UserPreferences) {
|
|
if preferences != nil {
|
|
defer f.ConfigureWithReset(t, *preferences)()
|
|
}
|
|
params := &lsproto.PrepareRenameParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
|
|
prefix := f.getCurrentPositionPrefix()
|
|
result := sendRequest(t, f, lsproto.TextDocumentPrepareRenameInfo, params)
|
|
if result.Range == nil && result.PrepareRenamePlaceholder == nil && result.PrepareRenameDefaultBehavior == nil {
|
|
t.Fatal(prefix + "Expected rename to succeed, but prepareRename returned null")
|
|
}
|
|
|
|
// Also verify that textDocument/rename produces edits, since prepareRename is optional.
|
|
renameResult := sendRequest(t, f, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
NewName: "RENAME_SUCCEEDED_TEST",
|
|
})
|
|
if renameResult.WorkspaceEdit == nil || renameResult.WorkspaceEdit.Changes == nil || len(*renameResult.WorkspaceEdit.Changes) == 0 {
|
|
t.Fatal(prefix + "prepareRename succeeded but textDocument/rename returned no changes")
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) RenameAtCaret(t *testing.T, newName string) lsproto.RenameResponse {
|
|
t.Helper()
|
|
result := sendRequest(t, f, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
NewName: newName,
|
|
})
|
|
|
|
if result.WorkspaceEdit == nil {
|
|
return result
|
|
}
|
|
|
|
if result.WorkspaceEdit.Changes != nil {
|
|
for uri, edits := range *result.WorkspaceEdit.Changes {
|
|
fileName := uri.FileName()
|
|
script := f.getOrLoadScriptInfo(fileName)
|
|
changes := core.Map(edits, func(edit *lsproto.TextEdit) core.TextChange {
|
|
return core.TextChange{
|
|
TextRange: f.converters.FromLSPRange(script, edit.Range),
|
|
NewText: edit.NewText,
|
|
}
|
|
})
|
|
f.editScriptAndUpdateMarkersWorker(t, fileName, changes)
|
|
}
|
|
}
|
|
|
|
var renameFiles []*lsproto.RenameFile
|
|
if result.WorkspaceEdit.DocumentChanges != nil {
|
|
for _, docChange := range *result.WorkspaceEdit.DocumentChanges {
|
|
if docChange.TextDocumentEdit != nil {
|
|
fileName := docChange.TextDocumentEdit.TextDocument.Uri.FileName()
|
|
script := f.getOrLoadScriptInfo(fileName)
|
|
changes := core.Map(docChange.TextDocumentEdit.Edits, func(edit lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit) core.TextChange {
|
|
textEdit := edit.TextEdit
|
|
return core.TextChange{
|
|
TextRange: f.converters.FromLSPRange(script, textEdit.Range),
|
|
NewText: textEdit.NewText,
|
|
}
|
|
})
|
|
f.editScriptAndUpdateMarkersWorker(t, fileName, changes)
|
|
} else if docChange.RenameFile != nil {
|
|
renameFiles = append(renameFiles, docChange.RenameFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(renameFiles) > 0 {
|
|
var fileRenames []*lsproto.FileRename
|
|
for _, renameFile := range renameFiles {
|
|
fileRenames = append(fileRenames, &lsproto.FileRename{
|
|
OldUri: string(renameFile.OldUri),
|
|
NewUri: string(renameFile.NewUri),
|
|
})
|
|
}
|
|
if f.capabilities != nil &&
|
|
f.capabilities.Workspace != nil &&
|
|
f.capabilities.Workspace.FileOperations != nil &&
|
|
f.capabilities.Workspace.FileOperations.WillRename != nil &&
|
|
*f.capabilities.Workspace.FileOperations.WillRename {
|
|
f.willRenameFilesWorker(t, fileRenames...)
|
|
} else {
|
|
for _, renameFile := range renameFiles {
|
|
f.renameFileOrDirectory(t, renameFile.OldUri.FileName(), renameFile.NewUri.FileName())
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (f *FourslashTest) WillRenameFiles(t *testing.T, files ...*lsproto.FileRename) lsproto.WillRenameFilesResponse {
|
|
t.Helper()
|
|
return sendRequest(t, f, lsproto.WorkspaceWillRenameFilesInfo, &lsproto.RenameFilesParams{
|
|
Files: files,
|
|
})
|
|
}
|
|
|
|
// Emulates a file rename by sending a workspace/willRenameFiles request and applying the resulting edits and file renames.
|
|
func (f *FourslashTest) willRenameFilesWorker(t *testing.T, files ...*lsproto.FileRename) {
|
|
t.Helper()
|
|
result := f.WillRenameFiles(t, files...)
|
|
|
|
if result.WorkspaceEdit == nil {
|
|
for _, file := range files {
|
|
oldPath := lsproto.DocumentUri(file.OldUri).FileName()
|
|
newPath := lsproto.DocumentUri(file.NewUri).FileName()
|
|
f.renameFileOrDirectory(t, oldPath, newPath)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result.WorkspaceEdit.Changes != nil {
|
|
for uri, edits := range *result.WorkspaceEdit.Changes {
|
|
fileName := uri.FileName()
|
|
script := f.getOrLoadScriptInfo(fileName)
|
|
changes := core.Map(edits, func(edit *lsproto.TextEdit) core.TextChange {
|
|
return core.TextChange{
|
|
TextRange: f.converters.FromLSPRange(script, edit.Range),
|
|
NewText: edit.NewText,
|
|
}
|
|
})
|
|
f.editScriptAndUpdateMarkersWorker(t, fileName, changes)
|
|
}
|
|
}
|
|
|
|
var renameFiles []*lsproto.RenameFile
|
|
if result.WorkspaceEdit.DocumentChanges != nil {
|
|
for _, docChange := range *result.WorkspaceEdit.DocumentChanges {
|
|
if docChange.TextDocumentEdit != nil {
|
|
fileName := docChange.TextDocumentEdit.TextDocument.Uri.FileName()
|
|
script := f.getOrLoadScriptInfo(fileName)
|
|
changes := core.Map(docChange.TextDocumentEdit.Edits, func(edit lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit) core.TextChange {
|
|
textEdit := edit.TextEdit
|
|
return core.TextChange{
|
|
TextRange: f.converters.FromLSPRange(script, textEdit.Range),
|
|
NewText: textEdit.NewText,
|
|
}
|
|
})
|
|
f.editScriptAndUpdateMarkersWorker(t, fileName, changes)
|
|
} else if docChange.RenameFile != nil {
|
|
renameFiles = append(renameFiles, docChange.RenameFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
var fileRenames []*lsproto.FileRename
|
|
for _, renameFile := range renameFiles {
|
|
fileRenames = append(fileRenames, &lsproto.FileRename{
|
|
OldUri: string(renameFile.OldUri),
|
|
NewUri: string(renameFile.NewUri),
|
|
})
|
|
}
|
|
f.willRenameFilesWorker(t, fileRenames...)
|
|
|
|
for _, file := range files {
|
|
oldPath := lsproto.DocumentUri(file.OldUri).FileName()
|
|
newPath := lsproto.DocumentUri(file.NewUri).FileName()
|
|
f.renameFileOrDirectory(t, oldPath, newPath)
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyRename(t *testing.T, markerName string, newName string, expectedFileContents map[string]string) {
|
|
t.Helper()
|
|
f.GoToMarker(t, markerName)
|
|
f.RenameAtCaret(t, newName)
|
|
for fileName, expectedContent := range expectedFileContents {
|
|
script := f.getScriptInfo(fileName)
|
|
if script == nil {
|
|
t.Fatalf("Expected script info for %s, but got nil", fileName)
|
|
}
|
|
assert.Equal(t, script.content, expectedContent, fmt.Sprintf("File content after rename did not match expected content for %s.", fileName))
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, newPath string, expectedFileContents map[string]string, preferences *lsutil.UserPreferences) {
|
|
t.Helper()
|
|
if preferences != nil {
|
|
defer f.ConfigureWithReset(t, *preferences)()
|
|
}
|
|
|
|
f.willRenameFilesWorker(t, &lsproto.FileRename{
|
|
OldUri: string(lsconv.FileNameToDocumentURI(oldPath)),
|
|
NewUri: string(lsconv.FileNameToDocumentURI(newPath)),
|
|
})
|
|
|
|
for fileName, expectedContent := range expectedFileContents {
|
|
script := f.getOrLoadScriptInfo(fileName)
|
|
if script == nil {
|
|
t.Fatalf("Expected script info for %s, but got nil", fileName)
|
|
}
|
|
assert.Equal(t, script.content, expectedContent, fmt.Sprintf("File content after workspace/willRenameFiles edits did not match expected content for %s.", fileName))
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) getPathUpdater(oldPath, newPath string) func(path string) (string, bool) {
|
|
return func(path string) (string, bool) {
|
|
compareOptions := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: f.vfs.UseCaseSensitiveFileNames()}
|
|
if tspath.ComparePaths(path, oldPath, compareOptions) == 0 {
|
|
return newPath, true
|
|
}
|
|
if tspath.StartsWithDirectory(path, oldPath, f.vfs.UseCaseSensitiveFileNames()) {
|
|
return newPath + path[len(oldPath):], true
|
|
}
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newPath string) {
|
|
t.Helper()
|
|
|
|
pathUpdater := f.getPathUpdater(oldPath, newPath)
|
|
|
|
// Collect all file paths that need to be renamed.
|
|
oldFileNames := map[string]struct{}{}
|
|
if _, ok := f.vfs.ReadFile(oldPath); ok {
|
|
oldFileNames[oldPath] = struct{}{}
|
|
} else {
|
|
walkErr := f.vfs.WalkDir(oldPath, func(path string, d vfs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() {
|
|
oldFileNames[path] = struct{}{}
|
|
}
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
t.Fatalf("failed to collect files for rename %s -> %s: %v", oldPath, newPath, walkErr)
|
|
}
|
|
}
|
|
if len(oldFileNames) == 0 {
|
|
t.Fatalf("rename source %s did not exist in test environment", oldPath)
|
|
}
|
|
|
|
// !!! TODO: handle overwrites if we need to.
|
|
// For each file: close if open, update script infos, write to VFS at new path, and collect file-watch events.
|
|
fileEvents := make([]*lsproto.FileEvent, 0, len(oldFileNames)*2)
|
|
reopenAtNewPath := map[string]string{} // newFileName -> content, for files that were open
|
|
for oldFileName := range oldFileNames {
|
|
newFileName, updated := pathUpdater(oldFileName)
|
|
if !updated {
|
|
t.Fatalf("failed to compute renamed path for %s", oldFileName)
|
|
}
|
|
|
|
// Send didClose for open files; get content from the old script info.
|
|
if _, isOpen := f.openFiles[oldFileName]; isOpen {
|
|
script := f.scriptInfos[oldFileName]
|
|
reopenAtNewPath[newFileName] = script.content
|
|
sendNotification(t, f, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(oldFileName),
|
|
},
|
|
})
|
|
delete(f.openFiles, oldFileName)
|
|
}
|
|
|
|
f.scriptInfos[newFileName] = newScriptInfo(newFileName, f.scriptInfos[oldFileName].content)
|
|
delete(f.scriptInfos, oldFileName)
|
|
|
|
// Write renamed file to VFS.
|
|
content, updated := f.vfs.ReadFile(oldFileName)
|
|
if !updated {
|
|
t.Fatalf("failed to read content for %s during rename to %s", oldFileName, newFileName)
|
|
}
|
|
if err := f.vfs.WriteFile(newFileName, content); err != nil {
|
|
t.Fatalf("failed to write renamed file %s: %v", newFileName, err)
|
|
}
|
|
|
|
fileEvents = append(
|
|
fileEvents,
|
|
&lsproto.FileEvent{Uri: lsconv.FileNameToDocumentURI(oldFileName), Type: lsproto.FileChangeTypeDeleted},
|
|
&lsproto.FileEvent{Uri: lsconv.FileNameToDocumentURI(newFileName), Type: lsproto.FileChangeTypeCreated},
|
|
)
|
|
}
|
|
|
|
// Remove the old path from VFS and notify the server of all file-system changes.
|
|
if err := f.vfs.Remove(oldPath); err != nil {
|
|
t.Fatalf("failed to remove old path %s: %v", oldPath, err)
|
|
}
|
|
sendNotification(t, f, lsproto.WorkspaceDidChangeWatchedFilesInfo, &lsproto.DidChangeWatchedFilesParams{
|
|
Changes: fileEvents,
|
|
})
|
|
|
|
// Reopen files that were previously open at their new paths.
|
|
for newFileName, content := range reopenAtNewPath {
|
|
sendNotification(t, f, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
|
TextDocument: &lsproto.TextDocumentItem{
|
|
Uri: lsconv.FileNameToDocumentURI(newFileName),
|
|
LanguageId: getLanguageKind(newFileName),
|
|
Text: content,
|
|
},
|
|
})
|
|
f.openFiles[newFileName] = struct{}{}
|
|
}
|
|
|
|
// Update active filename if it was under the renamed path.
|
|
if updatedActive, ok := pathUpdater(f.activeFilename); ok {
|
|
f.activeFilename = updatedActive
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyRenameFailed(t *testing.T, preferences *lsutil.UserPreferences) {
|
|
if preferences != nil {
|
|
defer f.ConfigureWithReset(t, *preferences)()
|
|
}
|
|
params := &lsproto.PrepareRenameParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
|
|
prefix := f.getCurrentPositionPrefix()
|
|
f.baselineState(t)
|
|
f.baselineRequestOrNotification(t, lsproto.TextDocumentPrepareRenameInfo.Method, params)
|
|
resMsg, result, _ := lsptestutil.SendRequest(t, f.client, lsproto.TextDocumentPrepareRenameInfo, params)
|
|
f.baselineState(t)
|
|
|
|
// prepareRename can reject via an error response (with a localized message) or a null result.
|
|
if resMsg != nil && resMsg.AsResponse().Error != nil {
|
|
// Error response — rename was rejected with a message. This is expected.
|
|
} else if result.Range != nil || result.PrepareRenamePlaceholder != nil || result.PrepareRenameDefaultBehavior != nil {
|
|
t.Fatalf("%sExpected rename to fail, but prepareRename returned a result", prefix)
|
|
}
|
|
|
|
// Also verify that textDocument/rename does not produce usable edits, since prepareRename is optional.
|
|
renameMsg, renameResult, _ := lsptestutil.SendRequest(t, f.client, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
NewName: "RENAME_FAILED_TEST",
|
|
})
|
|
if renameMsg != nil && renameMsg.AsResponse().Error != nil {
|
|
return
|
|
}
|
|
if renameResult.WorkspaceEdit != nil && renameResult.WorkspaceEdit.Changes != nil && len(*renameResult.WorkspaceEdit.Changes) > 0 {
|
|
t.Fatalf("%sprepareRename returned null but textDocument/rename returned changes", prefix)
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineRenameAtRangesWithText(
|
|
t *testing.T,
|
|
preferences *lsutil.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, testPath string) {
|
|
if !f.testData.isStateBaseliningEnabled() {
|
|
for command, content := range f.baselines {
|
|
baseline.Run(t, getBaselineFileName(t, command), content.String(), f.getBaselineOptions(command, testPath))
|
|
}
|
|
} else {
|
|
baseline.Run(t, getBaseFileNameFromTest(t)+".baseline", f.stateBaseline.baseline.String(), baseline.Options{Subfolder: "fourslash/state"})
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineInlayHints(
|
|
t *testing.T,
|
|
span *lsproto.Range,
|
|
testPreferences *lsutil.UserPreferences,
|
|
) {
|
|
fileName := f.activeFilename
|
|
var lspRange lsproto.Range
|
|
if span == nil {
|
|
lspRange = f.converters.ToLSPRange(f.getScriptInfo(fileName), core.NewTextRange(0, len(f.scriptInfos[fileName].content)))
|
|
} else {
|
|
lspRange = *span
|
|
}
|
|
|
|
params := &lsproto.InlayHintParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{Uri: lsconv.FileNameToDocumentURI(fileName)},
|
|
Range: lspRange,
|
|
}
|
|
|
|
preferences := testPreferences
|
|
if preferences == nil {
|
|
preferences = new(lsutil.NewDefaultUserPreferences())
|
|
}
|
|
reset := f.ConfigureWithReset(t, *preferences)
|
|
defer reset()
|
|
|
|
prefix := fmt.Sprintf("At position (Ln %d, Col %d): ", lspRange.Start.Line, lspRange.Start.Character)
|
|
result := sendRequest(t, f, lsproto.TextDocumentInlayHintInfo, params)
|
|
fileLines := strings.Split(f.getScriptInfo(fileName).content, "\n")
|
|
var annotations []string
|
|
if result.InlayHints != nil {
|
|
slices.SortFunc(*result.InlayHints, func(a, b *lsproto.InlayHint) int {
|
|
return lsproto.ComparePositions(a.Position, b.Position)
|
|
})
|
|
annotations = core.Map(*result.InlayHints, func(hint *lsproto.InlayHint) string {
|
|
if hint.Label.InlayHintLabelParts != nil {
|
|
for _, part := range *hint.Label.InlayHintLabelParts {
|
|
// Avoid diffs caused by lib file updates.
|
|
if part.Location != nil && isLibFile(part.Location.Uri.FileName()) {
|
|
part.Location.Range.Start = lsproto.Position{Line: 0, Character: 0}
|
|
part.Location.Range.End = lsproto.Position{Line: 0, Character: 0}
|
|
}
|
|
}
|
|
}
|
|
underline := strings.Repeat(" ", int(hint.Position.Character)) + "^"
|
|
hintJson, err := core.StringifyJson(hint, "", " ")
|
|
if err != nil {
|
|
t.Fatalf(prefix+"Failed to stringify inlay hint for baseline: %v", err)
|
|
}
|
|
annotation := fileLines[hint.Position.Line]
|
|
annotation += "\n" + underline + "\n" + hintJson
|
|
return annotation
|
|
})
|
|
}
|
|
|
|
if len(annotations) == 0 {
|
|
annotations = append(annotations, "=== No inlay hints ===")
|
|
}
|
|
|
|
f.addResultToBaseline(t, inlayHintsCmd, strings.Join(annotations, "\n\n"))
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineLinkedEditing(t *testing.T) {
|
|
baselineBuilder := &strings.Builder{}
|
|
offset := 0
|
|
|
|
// write to baseline in order of file appearance in test data
|
|
for _, file := range f.testData.Files {
|
|
fmt.Fprint(baselineBuilder, "// === Linked Editing ===\n")
|
|
fmt.Fprintf(baselineBuilder, "=== %s ===\n", file.FileName())
|
|
results := []*lsproto.LinkedEditingRanges{}
|
|
found := map[lsproto.Range]bool{}
|
|
|
|
// request linkedEditing at every position in the file
|
|
for i := range file.Content {
|
|
params := &lsproto.LinkedEditingRangeParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(file.FileName()),
|
|
},
|
|
Position: f.converters.PositionToLineAndCharacter(f.getScriptInfo(file.FileName()), core.TextPos(i)),
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentLinkedEditingRangeInfo, params)
|
|
if result.LinkedEditingRanges != nil && len(result.LinkedEditingRanges.Ranges) > 0 && !found[result.LinkedEditingRanges.Ranges[0]] {
|
|
results = append(results, result.LinkedEditingRanges)
|
|
found[result.LinkedEditingRanges.Ranges[0]] = true
|
|
}
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
fmt.Fprintf(baselineBuilder, "%s\n\n--No linked edits found--\n\n\n", file.Content)
|
|
continue
|
|
}
|
|
|
|
// sort entries in each file
|
|
slices.SortFunc(results, func(a, b *lsproto.LinkedEditingRanges) int {
|
|
return lsproto.ComparePositions(a.Ranges[0].Start, b.Ranges[0].Start)
|
|
})
|
|
baselineDetails := []baselineDetail{}
|
|
foundEditInfoBuilder := &strings.Builder{}
|
|
for _, edit := range results {
|
|
baselineDetails = append(baselineDetails, baselineDetail{
|
|
pos: edit.Ranges[0].Start,
|
|
positionMarker: fmt.Sprintf("[|/*%d*/", offset),
|
|
})
|
|
baselineDetails = append(baselineDetails, baselineDetail{
|
|
pos: edit.Ranges[0].End,
|
|
positionMarker: "|]",
|
|
})
|
|
baselineDetails = append(baselineDetails, baselineDetail{
|
|
pos: edit.Ranges[1].Start,
|
|
positionMarker: fmt.Sprintf("[|/*%d*/", offset),
|
|
})
|
|
baselineDetails = append(baselineDetails, baselineDetail{
|
|
pos: edit.Ranges[1].End,
|
|
positionMarker: "|]",
|
|
})
|
|
|
|
fmt.Fprintf(foundEditInfoBuilder, "\n\n=== %d ===\n%s", offset, core.Must(core.StringifyJson(edit, "", " ")))
|
|
offset++
|
|
}
|
|
|
|
// sort baselineDetails by position
|
|
slices.SortStableFunc(baselineDetails, func(a, b baselineDetail) int {
|
|
return lsproto.ComparePositions(a.pos, b.pos)
|
|
})
|
|
|
|
// write file content with inline annotations for linked edits
|
|
lastPosition := 0
|
|
for _, detail := range baselineDetails {
|
|
currentPosition := f.converters.LineAndCharacterToPosition(f.getScriptInfo(file.FileName()), detail.pos)
|
|
fmt.Fprint(baselineBuilder, file.Content[lastPosition:currentPosition])
|
|
fmt.Fprint(baselineBuilder, detail.positionMarker)
|
|
lastPosition = int(currentPosition)
|
|
}
|
|
fmt.Fprint(baselineBuilder, file.Content[lastPosition:])
|
|
baselineBuilder.WriteString(foundEditInfoBuilder.String() + "\n\n\n")
|
|
}
|
|
|
|
f.writeToBaseline(linkedEditingCmd, baselineBuilder.String())
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyLinkedEditing(t *testing.T, markerNamesToExpected map[string][]lsproto.Range) {
|
|
for markerName, expectedRanges := range markerNamesToExpected {
|
|
f.GoToMarker(t, markerName)
|
|
params := &lsproto.LinkedEditingRangeParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentLinkedEditingRangeInfo, params)
|
|
actualRanges := result.LinkedEditingRanges
|
|
if len(expectedRanges) == 0 {
|
|
if actualRanges != nil && len(actualRanges.Ranges) != 0 {
|
|
t.Fatalf("Expected no linked editing ranges for marker '%s', but found %v", markerName, actualRanges)
|
|
}
|
|
continue
|
|
} else {
|
|
if actualRanges == nil || len(actualRanges.Ranges) == 0 {
|
|
t.Fatalf("Expected linked editing ranges for marker '%s', but found none", markerName)
|
|
}
|
|
|
|
assertDeepEqual(t, actualRanges.Ranges[0], expectedRanges[0], fmt.Sprintf("Linked editing ranges for opening element do not match expected for marker '%s'", markerName))
|
|
assertDeepEqual(t, actualRanges.Ranges[1], expectedRanges[1], fmt.Sprintf("Linked editing ranges for closing element do not match expected for marker '%s'", markerName))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyDiagnostics(t *testing.T, expected []*lsproto.Diagnostic) {
|
|
f.verifyDiagnostics(t, expected, func(d *lsproto.Diagnostic) bool { return true })
|
|
}
|
|
|
|
// Similar to `VerifyDiagnostics`, but excludes suggestion diagnostics returned from server.
|
|
func (f *FourslashTest) VerifyNonSuggestionDiagnostics(t *testing.T, expected []*lsproto.Diagnostic) {
|
|
f.verifyDiagnostics(t, expected, func(d *lsproto.Diagnostic) bool { return !isSuggestionDiagnostic(d) })
|
|
}
|
|
|
|
// Similar to `VerifyDiagnostics`, but includes only suggestion diagnostics returned from server.
|
|
func (f *FourslashTest) VerifySuggestionDiagnostics(t *testing.T, expected []*lsproto.Diagnostic) {
|
|
f.verifyDiagnostics(t, expected, isSuggestionDiagnostic)
|
|
}
|
|
|
|
func (f *FourslashTest) verifyDiagnostics(t *testing.T, expected []*lsproto.Diagnostic, filterDiagnostics func(*lsproto.Diagnostic) bool) {
|
|
actualDiagnostics := f.getDiagnostics(t, f.activeFilename)
|
|
actualDiagnostics = core.Filter(actualDiagnostics, filterDiagnostics)
|
|
emptyRange := lsproto.Range{}
|
|
expectedWithRanges := make([]*lsproto.Diagnostic, len(expected))
|
|
for i, diag := range expected {
|
|
if diag.Range == emptyRange {
|
|
rangesInFile := f.getRangesInFile(f.activeFilename)
|
|
if len(rangesInFile) == 0 {
|
|
t.Fatalf("No ranges found in file %s to assign to diagnostic with empty range", f.activeFilename)
|
|
}
|
|
diagWithRange := *diag
|
|
diagWithRange.Range = rangesInFile[0].LSRange
|
|
expectedWithRanges[i] = &diagWithRange
|
|
} else {
|
|
expectedWithRanges[i] = diag
|
|
}
|
|
}
|
|
if len(actualDiagnostics) == 0 && len(expectedWithRanges) == 0 {
|
|
return
|
|
}
|
|
assertDeepEqual(t, actualDiagnostics, expectedWithRanges, "Diagnostics do not match expected", diagnosticsIgnoreOpts)
|
|
}
|
|
|
|
func (f *FourslashTest) getDiagnostics(t *testing.T, fileName string) []*lsproto.Diagnostic {
|
|
params := &lsproto.DocumentDiagnosticParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(fileName),
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentDiagnosticInfo, params)
|
|
if result.FullDocumentDiagnosticReport != nil {
|
|
return result.FullDocumentDiagnosticReport.Items
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSuggestionDiagnostic(diag *lsproto.Diagnostic) bool {
|
|
return diag.Severity != nil && *diag.Severity == lsproto.DiagnosticSeverityHint
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineNonSuggestionDiagnostics(t *testing.T) {
|
|
var diagnostics []*fourslashDiagnostic
|
|
var files []*harnessutil.TestFile
|
|
for fileName, scriptInfo := range f.scriptInfos {
|
|
if tspath.HasJSONFileExtension(fileName) {
|
|
continue
|
|
}
|
|
files = append(files, &harnessutil.TestFile{UnitName: fileName, Content: scriptInfo.content})
|
|
lspDiagnostics := core.Filter(
|
|
f.getDiagnostics(t, fileName),
|
|
func(d *lsproto.Diagnostic) bool { return !isSuggestionDiagnostic(d) },
|
|
)
|
|
diagnostics = append(diagnostics, core.Map(lspDiagnostics, func(d *lsproto.Diagnostic) *fourslashDiagnostic {
|
|
return f.toDiagnostic(scriptInfo, d)
|
|
})...)
|
|
}
|
|
slices.SortFunc(files, func(a, b *harnessutil.TestFile) int {
|
|
return strings.Compare(a.UnitName, b.UnitName)
|
|
})
|
|
result := tsbaseline.GetErrorBaseline(t, files, diagnostics, compareDiagnostics, false /*pretty*/)
|
|
f.addResultToBaseline(t, nonSuggestionDiagnosticsCmd, result)
|
|
}
|
|
|
|
type fourslashDiagnostic struct {
|
|
file *fourslashDiagnosticFile
|
|
loc core.TextRange
|
|
code int32
|
|
category diagnostics.Category
|
|
message string
|
|
relatedDiagnostics []*fourslashDiagnostic
|
|
reportsUnnecessary bool
|
|
reportsDeprecated bool
|
|
}
|
|
|
|
type fourslashDiagnosticFile struct {
|
|
file *harnessutil.TestFile
|
|
ecmaLineMap []core.TextPos
|
|
}
|
|
|
|
var _ diagnosticwriter.FileLike = (*fourslashDiagnosticFile)(nil)
|
|
|
|
func (f *fourslashDiagnosticFile) FileName() string {
|
|
return f.file.UnitName
|
|
}
|
|
|
|
func (f *fourslashDiagnosticFile) Text() string {
|
|
return f.file.Content
|
|
}
|
|
|
|
func (f *fourslashDiagnosticFile) ECMALineMap() []core.TextPos {
|
|
if f.ecmaLineMap == nil {
|
|
f.ecmaLineMap = core.ComputeECMALineStarts(f.file.Content)
|
|
}
|
|
return f.ecmaLineMap
|
|
}
|
|
|
|
var _ diagnosticwriter.Diagnostic = (*fourslashDiagnostic)(nil)
|
|
|
|
func (d *fourslashDiagnostic) File() diagnosticwriter.FileLike {
|
|
return d.file
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) Pos() int {
|
|
return d.loc.Pos()
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) End() int {
|
|
return d.loc.End()
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) Len() int {
|
|
return d.loc.Len()
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) Code() int32 {
|
|
return d.code
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) Category() diagnostics.Category {
|
|
return d.category
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) Localize(locale locale.Locale) string {
|
|
return d.message
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) MessageChain() []diagnosticwriter.Diagnostic {
|
|
return nil
|
|
}
|
|
|
|
func (d *fourslashDiagnostic) RelatedInformation() []diagnosticwriter.Diagnostic {
|
|
relatedInfo := make([]diagnosticwriter.Diagnostic, 0, len(d.relatedDiagnostics))
|
|
for _, relDiag := range d.relatedDiagnostics {
|
|
relatedInfo = append(relatedInfo, relDiag)
|
|
}
|
|
return relatedInfo
|
|
}
|
|
|
|
func (f *FourslashTest) toDiagnostic(scriptInfo *scriptInfo, lspDiagnostic *lsproto.Diagnostic) *fourslashDiagnostic {
|
|
var category diagnostics.Category
|
|
switch *lspDiagnostic.Severity {
|
|
case lsproto.DiagnosticSeverityError:
|
|
category = diagnostics.CategoryError
|
|
case lsproto.DiagnosticSeverityWarning:
|
|
category = diagnostics.CategoryWarning
|
|
case lsproto.DiagnosticSeverityInformation:
|
|
category = diagnostics.CategoryMessage
|
|
case lsproto.DiagnosticSeverityHint:
|
|
category = diagnostics.CategorySuggestion
|
|
default:
|
|
category = diagnostics.CategoryError
|
|
}
|
|
code := *lspDiagnostic.Code.Integer
|
|
|
|
var relatedDiagnostics []*fourslashDiagnostic
|
|
if lspDiagnostic.RelatedInformation != nil {
|
|
for _, info := range *lspDiagnostic.RelatedInformation {
|
|
relatedScriptInfo := f.getScriptInfo(info.Location.Uri.FileName())
|
|
if relatedScriptInfo == nil {
|
|
continue
|
|
}
|
|
relatedDiagnostic := &fourslashDiagnostic{
|
|
file: &fourslashDiagnosticFile{file: &harnessutil.TestFile{UnitName: relatedScriptInfo.fileName, Content: relatedScriptInfo.content}},
|
|
loc: f.converters.FromLSPRange(relatedScriptInfo, info.Location.Range),
|
|
code: code,
|
|
category: category,
|
|
message: info.Message,
|
|
}
|
|
relatedDiagnostics = append(relatedDiagnostics, relatedDiagnostic)
|
|
}
|
|
}
|
|
|
|
diagnostic := &fourslashDiagnostic{
|
|
file: &fourslashDiagnosticFile{
|
|
file: &harnessutil.TestFile{
|
|
UnitName: scriptInfo.fileName,
|
|
Content: scriptInfo.content,
|
|
},
|
|
},
|
|
loc: f.converters.FromLSPRange(scriptInfo, lspDiagnostic.Range),
|
|
code: code,
|
|
category: category,
|
|
message: lspDiagnostic.Message.AsString(),
|
|
relatedDiagnostics: relatedDiagnostics,
|
|
}
|
|
return diagnostic
|
|
}
|
|
|
|
func compareDiagnostics(d1, d2 *fourslashDiagnostic) int {
|
|
c := strings.Compare(d1.file.FileName(), d2.file.FileName())
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
c = d1.Pos() - d2.Pos()
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
c = d1.End() - d2.End()
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
c = int(d1.code) - int(d2.code)
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
c = strings.Compare(d1.message, d2.message)
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
return compareRelatedDiagnostics(d1.relatedDiagnostics, d2.relatedDiagnostics)
|
|
}
|
|
|
|
func compareRelatedDiagnostics(d1, d2 []*fourslashDiagnostic) int {
|
|
c := len(d2) - len(d1)
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
for i := range d1 {
|
|
c = compareDiagnostics(d1[i], d2[i])
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isLibFile(fileName string) bool {
|
|
baseName := tspath.GetBaseFileName(fileName)
|
|
if strings.HasPrefix(baseName, "lib.") && strings.HasSuffix(baseName, ".d.ts") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var AnyTextEdits *[]*lsproto.TextEdit
|
|
|
|
func (f *FourslashTest) VerifyBaselineGoToImplementation(t *testing.T, markerNames ...string) {
|
|
f.verifyBaselineDefinitions(
|
|
t,
|
|
goToImplementationCmd,
|
|
"/*GOTO IMPL*/", /*definitionMarker*/
|
|
func(t *testing.T, f *FourslashTest, fileName string, position lsproto.Position) lsproto.LocationOrLocationsOrDefinitionLinksOrNull {
|
|
params := &lsproto.ImplementationParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
Position: f.currentCaretPosition,
|
|
}
|
|
|
|
return sendRequest(t, f, lsproto.TextDocumentImplementationInfo, params)
|
|
},
|
|
false, /*includeOriginalSelectionRange*/
|
|
markerNames...,
|
|
)
|
|
}
|
|
|
|
type VerifyWorkspaceSymbolCase struct {
|
|
Pattern string
|
|
Includes *[]*lsproto.SymbolInformation
|
|
Exact *[]*lsproto.SymbolInformation
|
|
Preferences *lsutil.UserPreferences
|
|
}
|
|
|
|
// `verify.navigateTo` in Strada.
|
|
func (f *FourslashTest) VerifyWorkspaceSymbol(t *testing.T, cases []*VerifyWorkspaceSymbolCase) {
|
|
originalPreferences := f.userPreferences
|
|
for _, testCase := range cases {
|
|
preferences := testCase.Preferences
|
|
if preferences == nil {
|
|
preferences = new(lsutil.NewDefaultUserPreferences())
|
|
}
|
|
f.Configure(t, *preferences)
|
|
result := sendRequest(t, f, lsproto.WorkspaceSymbolInfo, &lsproto.WorkspaceSymbolParams{Query: testCase.Pattern})
|
|
if result.SymbolInformations == nil {
|
|
t.Fatalf("Expected non-nil symbol information array from workspace symbol request")
|
|
}
|
|
if testCase.Includes != nil {
|
|
if testCase.Exact != nil {
|
|
t.Fatalf("Test case cannot have both 'Includes' and 'Exact' fields set")
|
|
}
|
|
verifyIncludesSymbols(t, *result.SymbolInformations, *testCase.Includes, "Workspace symbols mismatch with pattern '"+testCase.Pattern+"'")
|
|
} else {
|
|
if testCase.Exact == nil {
|
|
t.Fatalf("Test case must have either 'Includes' or 'Exact' field set")
|
|
}
|
|
verifyExactSymbols(t, *result.SymbolInformations, *testCase.Exact, "Workspace symbols mismatch with pattern '"+testCase.Pattern+"'")
|
|
}
|
|
}
|
|
f.Configure(t, originalPreferences)
|
|
}
|
|
|
|
func verifyExactSymbols(
|
|
t *testing.T,
|
|
actual []*lsproto.SymbolInformation,
|
|
expected []*lsproto.SymbolInformation,
|
|
prefix string,
|
|
) {
|
|
if len(actual) != len(expected) {
|
|
t.Fatalf("%s: Expected %d symbols, but got %d:\n%s", prefix, len(expected), len(actual), cmp.Diff(actual, expected))
|
|
}
|
|
for i := range actual {
|
|
assertDeepEqual(t, actual[i], expected[i], prefix)
|
|
}
|
|
}
|
|
|
|
func verifyIncludesSymbols(
|
|
t *testing.T,
|
|
actual []*lsproto.SymbolInformation,
|
|
includes []*lsproto.SymbolInformation,
|
|
prefix string,
|
|
) {
|
|
type key struct {
|
|
name string
|
|
loc lsproto.Location
|
|
}
|
|
nameAndLocToActualSymbol := make(map[key]*lsproto.SymbolInformation, len(actual))
|
|
for _, sym := range actual {
|
|
nameAndLocToActualSymbol[key{name: sym.Name, loc: sym.Location}] = sym
|
|
}
|
|
|
|
for _, sym := range includes {
|
|
actualSym, ok := nameAndLocToActualSymbol[key{name: sym.Name, loc: sym.Location}]
|
|
if !ok {
|
|
t.Fatalf("%s: Expected symbol '%s' at location '%v' not found", prefix, sym.Name, sym.Location)
|
|
}
|
|
assertDeepEqual(t, actualSym, sym, fmt.Sprintf("%s: Symbol '%s' at location '%v' mismatch", prefix, sym.Name, sym.Location))
|
|
}
|
|
}
|
|
|
|
func (f *FourslashTest) VerifyBaselineDocumentSymbol(t *testing.T) {
|
|
params := &lsproto.DocumentSymbolParams{
|
|
TextDocument: lsproto.TextDocumentIdentifier{
|
|
Uri: lsconv.FileNameToDocumentURI(f.activeFilename),
|
|
},
|
|
}
|
|
result := sendRequest(t, f, lsproto.TextDocumentDocumentSymbolInfo, params)
|
|
uri := lsconv.FileNameToDocumentURI(f.activeFilename)
|
|
symbolBySpan := make(map[documentSpanKey]*lsproto.DocumentSymbol)
|
|
if result.DocumentSymbols != nil {
|
|
for _, symbol := range *result.DocumentSymbols {
|
|
collectDocumentSymbolSpans(uri, symbol, symbolBySpan)
|
|
}
|
|
}
|
|
spans := make([]documentSpan, 0, len(symbolBySpan))
|
|
for key, symbol := range symbolBySpan {
|
|
spans = append(spans, documentSpan{uri: key.uri, textSpan: key.textSpan, contextSpan: &symbol.Range})
|
|
}
|
|
f.addResultToBaseline(
|
|
t,
|
|
documentSymbolsCmd,
|
|
f.getBaselineForSpansWithFileContents(spans, baselineFourslashLocationsOptions{
|
|
getLocationData: func(span documentSpan) string {
|
|
symbol := symbolBySpan[documentSpanKey{uri: span.uri, textSpan: span.textSpan, contextSpan: *span.contextSpan}]
|
|
return fmt.Sprintf("{| name: %s, kind: %s |}", symbol.Name, symbol.Kind.String())
|
|
},
|
|
}),
|
|
)
|
|
|
|
var detailsBuilder strings.Builder
|
|
if result.DocumentSymbols != nil {
|
|
writeDocumentSymbolDetails(*result.DocumentSymbols, 0, &detailsBuilder)
|
|
}
|
|
f.writeToBaseline(documentSymbolsCmd, "\n\n// === Details ===\n"+detailsBuilder.String())
|
|
}
|
|
|
|
func writeDocumentSymbolDetails(symbols []*lsproto.DocumentSymbol, indent int, builder *strings.Builder) {
|
|
for _, symbol := range symbols {
|
|
fmt.Fprintf(builder, "%s(%s) %s\n", strings.Repeat(" ", indent), symbol.Kind.String(), symbol.Name)
|
|
if symbol.Children != nil {
|
|
writeDocumentSymbolDetails(*symbol.Children, indent+1, builder)
|
|
}
|
|
}
|
|
}
|
|
|
|
func collectDocumentSymbolSpans(
|
|
uri lsproto.DocumentUri,
|
|
symbol *lsproto.DocumentSymbol,
|
|
symbolBySpan map[documentSpanKey]*lsproto.DocumentSymbol,
|
|
) {
|
|
// Deduplicate by value rather than by the documentSpan key, which holds a pointer to
|
|
// the symbol's Range. The same logical symbol can be reached more than once
|
|
// (e.g. a merged declaration), and depending on transport those occurrences may
|
|
// be the same object (shared *Range) or independent copies (distinct *Range
|
|
// after a JSON round-trip). A value-based key collapses them consistently.
|
|
key := documentSpanKey{uri: uri, textSpan: symbol.SelectionRange, contextSpan: symbol.Range}
|
|
if _, ok := symbolBySpan[key]; !ok {
|
|
symbolBySpan[key] = symbol
|
|
}
|
|
if symbol.Children != nil {
|
|
for _, child := range *symbol.Children {
|
|
collectDocumentSymbolSpans(uri, child, symbolBySpan)
|
|
}
|
|
}
|
|
}
|
|
|
|
// documentSpanKey is a value-comparable variant of documentSpan used to deduplicate
|
|
// document symbols regardless of pointer identity.
|
|
type documentSpanKey struct {
|
|
uri lsproto.DocumentUri
|
|
textSpan lsproto.Range
|
|
contextSpan lsproto.Range
|
|
}
|
|
|
|
// VerifyNumberOfErrorsInCurrentFile verifies that the current file has the expected number of errors.
|
|
func (f *FourslashTest) VerifyNumberOfErrorsInCurrentFile(t *testing.T, expectedCount int) {
|
|
diagnostics := f.getDiagnostics(t, f.activeFilename)
|
|
// Filter to only include errors (not suggestions/hints)
|
|
errors := core.Filter(diagnostics, func(d *lsproto.Diagnostic) bool {
|
|
return !isSuggestionDiagnostic(d)
|
|
})
|
|
if len(errors) != expectedCount {
|
|
t.Fatalf("Expected %d errors in current file, but got %d", expectedCount, len(errors))
|
|
}
|
|
}
|
|
|
|
// VerifyNoErrors verifies that no errors exist in any open files.
|
|
func (f *FourslashTest) VerifyNoErrors(t *testing.T) {
|
|
for fileName := range f.openFiles {
|
|
diagnostics := f.getDiagnostics(t, fileName)
|
|
// Filter to only include errors (not suggestions/hints)
|
|
errors := core.Filter(diagnostics, func(d *lsproto.Diagnostic) bool {
|
|
return !isSuggestionDiagnostic(d)
|
|
})
|
|
if len(errors) > 0 {
|
|
var messages []string
|
|
for _, err := range errors {
|
|
messages = append(messages, err.Message.AsString())
|
|
}
|
|
t.Fatalf("Expected no errors but found %d in %s: %v", len(errors), fileName, messages)
|
|
}
|
|
}
|
|
}
|
|
|
|
// VerifyErrorExistsAtRange verifies that an error with the given code exists at the given range.
|
|
func (f *FourslashTest) VerifyErrorExistsAtRange(t *testing.T, rangeMarker *RangeMarker, code int, message string) {
|
|
diagnostics := f.getDiagnostics(t, rangeMarker.FileName())
|
|
for _, diag := range diagnostics {
|
|
if diag.Code != nil && diag.Code.Integer != nil && int(*diag.Code.Integer) == code {
|
|
// Check if the range matches
|
|
if diag.Range.Start.Line == rangeMarker.LSRange.Start.Line &&
|
|
diag.Range.Start.Character == rangeMarker.LSRange.Start.Character &&
|
|
diag.Range.End.Line == rangeMarker.LSRange.End.Line &&
|
|
diag.Range.End.Character == rangeMarker.LSRange.End.Character {
|
|
// If message is provided, verify it matches
|
|
if message != "" && diag.Message.AsString() != message {
|
|
t.Fatalf("Error at range has code %d but message mismatch. Expected: %q, Got: %q", code, message, diag.Message.AsString())
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("Expected error with code %d at range %v but it was not found", code, rangeMarker.LSRange)
|
|
}
|
|
|
|
// VerifyErrorExistsBetweenMarkers verifies that an error exists between the two markers.
|
|
func (f *FourslashTest) VerifyErrorExistsBetweenMarkers(t *testing.T, startMarkerName string, endMarkerName string) {
|
|
startMarker, ok := f.testData.MarkerPositions[startMarkerName]
|
|
if !ok {
|
|
t.Fatalf("Start marker '%s' not found", startMarkerName)
|
|
}
|
|
endMarker, ok := f.testData.MarkerPositions[endMarkerName]
|
|
if !ok {
|
|
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)
|
|
}
|
|
|
|
diagnostics := f.getDiagnostics(t, startMarker.FileName())
|
|
startPos := startMarker.Position
|
|
endPos := endMarker.Position
|
|
|
|
for _, diag := range diagnostics {
|
|
if !isSuggestionDiagnostic(diag) {
|
|
diagStart := int(f.converters.LineAndCharacterToPosition(f.getScriptInfo(startMarker.FileName()), diag.Range.Start))
|
|
diagEnd := int(f.converters.LineAndCharacterToPosition(f.getScriptInfo(startMarker.FileName()), diag.Range.End))
|
|
if diagStart >= startPos && diagEnd <= endPos {
|
|
return // Found an error in the range
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("Expected error between markers '%s' and '%s' but none was found", startMarkerName, endMarkerName)
|
|
}
|
|
|
|
// VerifyErrorExistsAfterMarker verifies that an error exists after the given marker.
|
|
func (f *FourslashTest) VerifyErrorExistsAfterMarker(t *testing.T, markerName string) {
|
|
var fileName string
|
|
var markerPos int
|
|
|
|
if markerName == "" {
|
|
// Use current position
|
|
fileName = f.activeFilename
|
|
markerPos = int(f.converters.LineAndCharacterToPosition(f.getScriptInfo(f.activeFilename), f.currentCaretPosition))
|
|
} else {
|
|
marker, ok := f.testData.MarkerPositions[markerName]
|
|
if !ok {
|
|
t.Fatalf("Marker '%s' not found", markerName)
|
|
}
|
|
fileName = marker.FileName()
|
|
markerPos = marker.Position
|
|
}
|
|
|
|
diagnostics := f.getDiagnostics(t, fileName)
|
|
|
|
for _, diag := range diagnostics {
|
|
if !isSuggestionDiagnostic(diag) {
|
|
diagStart := int(f.converters.LineAndCharacterToPosition(f.getScriptInfo(fileName), diag.Range.Start))
|
|
if diagStart >= markerPos {
|
|
return // Found an error after the marker
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("Expected error after marker '%s' but none was found", markerName)
|
|
}
|
|
|
|
// VerifyErrorExistsBeforeMarker verifies that an error exists before the given marker.
|
|
func (f *FourslashTest) VerifyErrorExistsBeforeMarker(t *testing.T, markerName string) {
|
|
var fileName string
|
|
var markerPos int
|
|
|
|
if markerName == "" {
|
|
// Use current position
|
|
fileName = f.activeFilename
|
|
markerPos = int(f.converters.LineAndCharacterToPosition(f.getScriptInfo(f.activeFilename), f.currentCaretPosition))
|
|
} else {
|
|
marker, ok := f.testData.MarkerPositions[markerName]
|
|
if !ok {
|
|
t.Fatalf("Marker '%s' not found", markerName)
|
|
}
|
|
fileName = marker.FileName()
|
|
markerPos = marker.Position
|
|
}
|
|
|
|
diagnostics := f.getDiagnostics(t, fileName)
|
|
|
|
for _, diag := range diagnostics {
|
|
if !isSuggestionDiagnostic(diag) {
|
|
diagEnd := int(f.converters.LineAndCharacterToPosition(f.getScriptInfo(fileName), diag.Range.End))
|
|
if diagEnd <= markerPos {
|
|
return // Found an error before the marker
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("Expected error before marker '%s' but none was found", markerName)
|
|
}
|
|
|
|
func updatePositionForTextEdit(position int, editStart int, editEnd int, newTextLength int) int {
|
|
if position <= editStart {
|
|
return position
|
|
}
|
|
if position < editEnd {
|
|
return -1
|
|
}
|
|
return position + newTextLength - (editEnd - editStart)
|
|
}
|
|
|
|
func removeWhitespace(text string) string {
|
|
var builder strings.Builder
|
|
for _, ch := range text {
|
|
if stringutil.IsWhiteSpaceLike(ch) {
|
|
continue
|
|
}
|
|
builder.WriteRune(ch)
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func assertValidTextRange(t *testing.T, textRange core.TextRange, message string) {
|
|
t.Helper()
|
|
if textRange.Pos() >= 0 && textRange.End() >= 0 {
|
|
return
|
|
}
|
|
t.Fatal(message)
|
|
}
|
|
|
|
func selectCodeFixDiagnostic(diagnostics []*lsproto.Diagnostic, errorCode int) *lsproto.Diagnostic {
|
|
if errorCode == 0 {
|
|
return diagnostics[0]
|
|
}
|
|
return core.Find(diagnostics, func(diagnostic *lsproto.Diagnostic) bool {
|
|
return diagnostic.Code != nil && diagnostic.Code.Integer != nil && *diagnostic.Code.Integer == int32(errorCode)
|
|
})
|
|
}
|