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