internal/fourslash/baselineutil.go (790 lines of code) (raw):

package fourslash import ( "cmp" "errors" "fmt" "io/fs" "regexp" "slices" "strings" "testing" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/debug" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/stringutil" "github.com/microsoft/typescript-go/internal/testutil/baseline" "github.com/microsoft/typescript-go/internal/vfs" ) const ( autoImportsCmd baselineCommand = "Auto Imports" callHierarchyCmd baselineCommand = "Call Hierarchy" documentHighlightsCmd baselineCommand = "documentHighlights" findAllReferencesCmd baselineCommand = "findAllReferences" goToDefinitionCmd baselineCommand = "goToDefinition" goToImplementationCmd baselineCommand = "goToImplementation" goToTypeDefinitionCmd baselineCommand = "goToType" inlayHintsCmd baselineCommand = "Inlay Hints" nonSuggestionDiagnosticsCmd baselineCommand = "Syntax and Semantic Diagnostics" quickInfoCmd baselineCommand = "QuickInfo" renameCmd baselineCommand = "findRenameLocations" signatureHelpCmd baselineCommand = "SignatureHelp" smartSelectionCmd baselineCommand = "Smart Selection" codeLensesCmd baselineCommand = "Code Lenses" ) type baselineCommand string func (f *FourslashTest) addResultToBaseline(t *testing.T, command baselineCommand, actual string) { var b *strings.Builder if f.testData.isStateBaseliningEnabled() { // Single baseline for all commands b = &f.stateBaseline.baseline } else if builder, ok := f.baselines[command]; ok { b = builder } else { f.baselines[command] = &strings.Builder{} b = f.baselines[command] } if b.Len() != 0 { b.WriteString("\n\n\n\n") } b.WriteString(`// === ` + string(command) + " ===\n" + actual) } func (f *FourslashTest) writeToBaseline(command baselineCommand, content string) { b, ok := f.baselines[command] if !ok { f.baselines[command] = &strings.Builder{} b = f.baselines[command] } b.WriteString(content) } func getBaselineFileName(t *testing.T, command baselineCommand) string { return getBaseFileNameFromTest(t) + "." + getBaselineExtension(command) } func getBaselineExtension(command baselineCommand) string { switch command { case quickInfoCmd, signatureHelpCmd, smartSelectionCmd, inlayHintsCmd, nonSuggestionDiagnosticsCmd: return "baseline" case callHierarchyCmd: return "callHierarchy.txt" case autoImportsCmd: return "baseline.md" default: return "baseline.jsonc" } } func (f *FourslashTest) getBaselineOptions(command baselineCommand, testPath string) baseline.Options { subfolder := "fourslash/" + normalizeCommandName(string(command)) if !isSubmoduleTest(testPath) { return baseline.Options{ Subfolder: subfolder, } } switch command { case smartSelectionCmd: return baseline.Options{ Subfolder: subfolder, IsSubmodule: true, } case callHierarchyCmd: return baseline.Options{ Subfolder: subfolder, IsSubmodule: true, DiffFixupOld: func(s string) string { // TypeScript baselines have "/tests/cases/fourslash/" prefix in file paths // Handle /server/ subdirectory - need to remove both prefixes s = strings.ReplaceAll(s, "/tests/cases/fourslash/server/", "/") s = strings.ReplaceAll(s, "/tests/cases/fourslash/", "/") // SymbolKind enum differences between Strada and tsgo s = strings.ReplaceAll(s, "kind: getter", "kind: property") s = strings.ReplaceAll(s, "kind: script", "kind: file") return s }, } case renameCmd: return baseline.Options{ Subfolder: subfolder, IsSubmodule: true, DiffFixupOld: func(s string) string { var commandLines []string commandPrefix := regexp.MustCompile(`^// === ([a-z\sA-Z]*) ===`) testFilePrefix := "/tests/cases/fourslash" serverTestFilePrefix := "/server" contextSpanOpening := "<|" contextSpanClosing := "|>" oldPreference := "providePrefixAndSuffixTextForRename" newPreference := "useAliasesForRename" replacer := strings.NewReplacer( contextSpanOpening, "", contextSpanClosing, "", testFilePrefix, "", serverTestFilePrefix, "", oldPreference, newPreference, ) lines := strings.Split(s, "\n") var isInCommand bool for _, line := range lines { if strings.HasPrefix(line, "// @findInStrings: ") || strings.HasPrefix(line, "// @findInComments: ") { continue } matches := commandPrefix.FindStringSubmatch(line) if len(matches) > 0 { commandName := matches[1] if commandName == string(command) { isInCommand = true } else { isInCommand = false } } if isInCommand { fixedLine := replacer.Replace(line) commandLines = append(commandLines, fixedLine) } } return strings.Join(dropTrailingEmptyLines(commandLines), "\n") }, } case inlayHintsCmd: return baseline.Options{ Subfolder: subfolder, IsSubmodule: true, DiffFixupOld: func(s string) string { var commandLines []string commandPrefix := regexp.MustCompile(`^// === ([a-z\sA-Z]*) ===`) lines := strings.Split(s, "\n") var isInCommand bool replacer := strings.NewReplacer( `"whitespaceAfter"`, `"paddingRight"`, `"whitespaceBefore"`, `"paddingLeft"`, ) hintStart := -1 for i := 0; i < len(lines); i++ { line := lines[i] matches := commandPrefix.FindStringSubmatch(line) if len(matches) > 0 { commandName := matches[1] if commandName == string(command) { isInCommand = true } else { isInCommand = false } } if isInCommand { if line == "{" { hintStart = len(commandLines) } if line == "}" && strings.HasSuffix(commandLines[len(commandLines)-1], ",") { commandLines[len(commandLines)-1] = strings.TrimSuffix(commandLines[len(commandLines)-1], ",") } trimmedLine := strings.TrimSpace(line) // Ignore position, already verified via caret. if strings.HasPrefix(trimmedLine, `"position": `) { continue } if strings.HasPrefix(trimmedLine, `"text": `) { if trimmedLine == `"text": "",` { continue } line = strings.Replace(line, `"text":`, `"label":`, 1) } if strings.HasPrefix(trimmedLine, `"kind": `) { switch trimmedLine { case `"kind": "Parameter",`: line = strings.Replace(line, `"kind": "Parameter",`, `"kind": 2,`, 1) case `"kind": "Type",`: line = strings.Replace(line, `"kind": "Type",`, `"kind": 1,`, 1) default: continue } } // Compare only text/value of display parts. // Record the presence of a span but not its details. if strings.HasPrefix(trimmedLine, `"displayParts": `) { var displayPartLines []string displayPartLines = append(displayPartLines, strings.Replace(line, "displayParts", "label", 1)) var j int for j = i + 1; j < len(lines); j++ { line := lines[j] trimmedLine := strings.TrimSpace(line) if strings.HasPrefix(trimmedLine, `"text": `) { line = strings.Replace(line, `"text":`, `"value":`, 1) } else if strings.HasPrefix(trimmedLine, `"span": `) { displayPartLines = append(displayPartLines, strings.Replace(line, "span", "location", 1)+"},") j = j + 3 continue } else if strings.HasPrefix(trimmedLine, `"file": `) { continue } if trimmedLine == "]" || trimmedLine == "]," { fixedLine := line if trimmedLine == "]" { fixedLine += "," } displayPartLines = append(displayPartLines, fixedLine) break } displayPartLines = append(displayPartLines, line) } // Add display parts at beginning of hint. commandLines = slices.Insert(commandLines, hintStart+1, displayPartLines...) i = j continue } fixedLine := replacer.Replace(line) commandLines = append(commandLines, fixedLine) } } return strings.Join(dropTrailingEmptyLines(commandLines), "\n") }, DiffFixupNew: func(s string) string { lines := strings.Split(s, "\n") var fixedLines []string for i := 0; i < len(lines); i++ { line := lines[i] trimmedLine := strings.TrimSpace(line) if strings.HasPrefix(trimmedLine, `"position": `) { i = i + 3 continue } if strings.HasPrefix(trimmedLine, `"location": `) { fixedLines = append(fixedLines, line+"},") i = i + 12 continue } fixedLines = append(fixedLines, line) } return strings.Join(fixedLines, "\n") }, } case goToDefinitionCmd, goToTypeDefinitionCmd, goToImplementationCmd: return baseline.Options{ Subfolder: subfolder, IsSubmodule: true, DiffFixupOld: func(s string) string { var commandLines []string commandPrefix := regexp.MustCompile(`^// === ([a-z\sA-Z]*) ===`) testFilePrefix := "/tests/cases/fourslash" serverTestFilePrefix := "/server" oldGoToDefCommand := "getDefinitionAtPosition" oldGoToDefComment := "/*GOTO DEF POS*/" replacer := strings.NewReplacer( testFilePrefix, "", serverTestFilePrefix, "", oldGoToDefCommand, string(goToDefinitionCmd), oldGoToDefComment, "/*GOTO DEF*/", ) objectRangeRegex := regexp.MustCompile(`{\| [^|]* \|}`) detailsStr := "// === Details ===" lines := strings.Split(s, "\n") var isInCommand bool var isInDetails bool for _, line := range lines { matches := commandPrefix.FindStringSubmatch(line) if len(matches) > 0 { isInDetails = false commandName := matches[1] if commandName == string(command) || command == goToDefinitionCmd && commandName == oldGoToDefCommand { isInCommand = true } else { isInCommand = false } } if isInCommand { if strings.Contains(line, detailsStr) { // Drop blank line before details commandLines = commandLines[:len(commandLines)-1] isInDetails = true } // We don't diff the details section, since the structure of responses is different. if !isInDetails { fixedLine := replacer.Replace(line) fixedLine = objectRangeRegex.ReplaceAllString(fixedLine, "") commandLines = append(commandLines, fixedLine) } else if line == " ]" { isInDetails = false } } } return strings.Join(dropTrailingEmptyLines(commandLines), "\n") }, DiffFixupNew: func(s string) string { return strings.ReplaceAll(s, "bundled:///libs/", "") }, } default: return baseline.Options{ Subfolder: subfolder, } } } func dropTrailingEmptyLines(ss []string) []string { return ss[:core.FindLastIndex(ss, func(s string) bool { return s != "" })+1] } func isSubmoduleTest(testPath string) bool { return strings.Contains(testPath, "fourslash/tests/gen") || strings.Contains(testPath, "fourslash/tests/manual") } func normalizeCommandName(command string) string { words := strings.Fields(command) command = strings.Join(words, "") return stringutil.LowerFirstChar(command) } type documentSpan struct { uri lsproto.DocumentUri textSpan lsproto.Range contextSpan *lsproto.Range } type baselineFourslashLocationsOptions struct { // markerInfo marker MarkerOrRange // location markerName string // name of the marker to be printed in baseline endMarker string startMarkerPrefix func(span documentSpan) *string endMarkerSuffix func(span documentSpan) *string getLocationData func(span documentSpan) string additionalSpan *documentSpan } func locationToSpan(loc lsproto.Location) documentSpan { return documentSpan{ uri: loc.Uri, textSpan: loc.Range, } } func (f *FourslashTest) getBaselineForLocationsWithFileContents(locations []lsproto.Location, options baselineFourslashLocationsOptions) string { return f.getBaselineForSpansWithFileContents( core.Map(locations, locationToSpan), options, ) } func (f *FourslashTest) getBaselineForSpansWithFileContents(spans []documentSpan, options baselineFourslashLocationsOptions) string { spansByFile := collections.GroupBy(spans, func(span documentSpan) lsproto.DocumentUri { return span.uri }) return f.getBaselineForGroupedSpansWithFileContents( spansByFile, options, ) } func (f *FourslashTest) getBaselineForGroupedSpansWithFileContents(groupedRanges *collections.MultiMap[lsproto.DocumentUri, documentSpan], options baselineFourslashLocationsOptions) string { // We must always print the file containing the marker, // but don't want to print it twice at the end if it already // found in a file with ranges. foundMarker := false foundAdditionalLocation := false spanToContextId := map[documentSpan]int{} baselineEntries := []string{} walkDirFn := func(path string, d vfs.DirEntry, e error) error { if e != nil { return e } if !d.Type().IsRegular() { return nil } fileName := lsconv.FileNameToDocumentURI(path) ranges := groupedRanges.Get(fileName) if len(ranges) == 0 { return nil } content, ok := f.textOfFile(path) if !ok { // !!! error? return nil } if options.marker != nil && options.marker.FileName() == path { foundMarker = true } if options.additionalSpan != nil && options.additionalSpan.uri == fileName { foundAdditionalLocation = true } baselineEntries = append(baselineEntries, f.getBaselineContentForFile(path, content, ranges, spanToContextId, options)) return nil } err := f.vfs.WalkDir("/", walkDirFn) if err != nil && !errors.Is(err, fs.ErrNotExist) { panic("walkdir error during fourslash baseline: " + err.Error()) } err = f.vfs.WalkDir("bundled:///", walkDirFn) if err != nil && !errors.Is(err, fs.ErrNotExist) { panic("walkdir error during fourslash baseline: " + err.Error()) } // In Strada, there is a bug where we only ever add additional spans to baselines if we haven't // already added the file to the baseline. if options.additionalSpan != nil && !foundAdditionalLocation { fileName := options.additionalSpan.uri.FileName() if content, ok := f.textOfFile(fileName); ok { baselineEntries = append( baselineEntries, f.getBaselineContentForFile(fileName, content, []documentSpan{*options.additionalSpan}, spanToContextId, options), ) if options.marker != nil && options.marker.FileName() == fileName { foundMarker = true } } } if !foundMarker && options.marker != nil { // If we didn't find the marker in any file, we need to add it. markerFileName := options.marker.FileName() if content, ok := f.textOfFile(markerFileName); ok { baselineEntries = append(baselineEntries, f.getBaselineContentForFile(markerFileName, content, nil, spanToContextId, options)) } } // !!! skipDocumentContainingOnlyMarker return strings.Join(baselineEntries, "\n\n") } func (f *FourslashTest) textOfFile(fileName string) (string, bool) { if _, ok := f.openFiles[fileName]; ok { return f.getScriptInfo(fileName).content, true } return f.vfs.ReadFile(fileName) } type baselineDetail struct { pos lsproto.Position positionMarker string span *documentSpan kind string } func (f *FourslashTest) getBaselineContentForFile( fileName string, content string, spansInFile []documentSpan, spanToContextId map[documentSpan]int, options baselineFourslashLocationsOptions, ) string { details := []*baselineDetail{} detailPrefixes := map[*baselineDetail]string{} detailSuffixes := map[*baselineDetail]string{} canDetermineContextIdInline := true if options.marker != nil && options.marker.FileName() == fileName { details = append(details, &baselineDetail{pos: options.marker.LSPos(), positionMarker: options.markerName}) } for _, span := range spansInFile { contextSpanIndex := len(details) // Add context span markers if present if span.contextSpan != nil { details = append(details, &baselineDetail{ pos: span.contextSpan.Start, positionMarker: "<|", span: &span, kind: "contextStart", }) // Check if context span starts after text span if lsproto.ComparePositions(span.contextSpan.Start, span.textSpan.Start) > 0 { canDetermineContextIdInline = false } } textSpanIndex := len(details) startMarker := "[|" if options.getLocationData != nil { startMarker += options.getLocationData(span) } details = append(details, &baselineDetail{pos: span.textSpan.Start, positionMarker: startMarker, span: &span, kind: "textStart"}, &baselineDetail{pos: span.textSpan.End, positionMarker: core.OrElse(options.endMarker, "|]"), span: &span, kind: "textEnd"}, ) if span.contextSpan != nil { details = append(details, &baselineDetail{ pos: span.contextSpan.End, positionMarker: "|>", span: &span, kind: "contextEnd", }) } if options.startMarkerPrefix != nil { startPrefix := options.startMarkerPrefix(span) if startPrefix != nil { // Special case: if this span starts at the same position as the provided marker, // we want the span's prefix to appear before the marker name. // i.e. We want `/*START PREFIX*/A: /*RENAME*/[|ARENAME|]`, // not `/*RENAME*//*START PREFIX*/A: [|ARENAME|]` if options.marker != nil && fileName == options.marker.FileName() && span.textSpan.Start == options.marker.LSPos() { _, ok := detailPrefixes[details[0]] debug.Assert(!ok, "Expected only single prefix at marker location") detailPrefixes[details[0]] = *startPrefix } else if span.contextSpan != nil && span.contextSpan.Start == span.textSpan.Start { detailPrefixes[details[contextSpanIndex]] = *startPrefix } else { detailPrefixes[details[textSpanIndex]] = *startPrefix } } } if options.endMarkerSuffix != nil { endSuffix := options.endMarkerSuffix(span) if endSuffix != nil { // Same as above for suffixes: if options.marker != nil && fileName == options.marker.FileName() && span.textSpan.End == options.marker.LSPos() { detailSuffixes[details[0]] = *endSuffix } else if span.contextSpan != nil && span.contextSpan.End == span.textSpan.End { detailSuffixes[details[textSpanIndex+2]] = *endSuffix } else { detailSuffixes[details[textSpanIndex+1]] = *endSuffix } } } } slices.SortStableFunc(details, func(d1, d2 *baselineDetail) int { return lsproto.ComparePositions(d1.pos, d2.pos) }) // !!! if canDetermineContextIdInline textWithContext := newTextWithContext(fileName, content) // Our preferred way to write marker is // /*MARKER*/[| some text |] // [| some /*MARKER*/ text |] // [| some text |]/*MARKER*/ // Stable sort should handle first two cases but with that marker will be before rangeEnd if locations match // So we will defer writing marker in this case by checking and finding index of rangeEnd if same var deferredMarkerIndex *int for index, detail := range details { if detail.span == nil && deferredMarkerIndex == nil { // If this is marker position and its same as textEnd and/or contextEnd we want to write marker after those for matchingEndPosIndex := index + 1; matchingEndPosIndex < len(details); matchingEndPosIndex++ { // Defer after the location if its same as rangeEnd if details[matchingEndPosIndex].pos == detail.pos && strings.HasSuffix(details[matchingEndPosIndex].kind, "End") { deferredMarkerIndex = ptrTo(matchingEndPosIndex) } // Dont defer further than already determined break } // Defer writing marker position to deffered marker index if deferredMarkerIndex != nil { continue } } textWithContext.add(detail) textWithContext.pos = detail.pos // Prefix prefix := detailPrefixes[detail] if prefix != "" { textWithContext.newContent.WriteString(prefix) } textWithContext.newContent.WriteString(detail.positionMarker) if detail.span != nil { switch detail.kind { case "textStart": var text string if contextId, ok := spanToContextId[*detail.span]; ok { isAfterContextStart := false for textStartIndex := index - 1; textStartIndex >= 0; textStartIndex-- { textStartDetail := details[textStartIndex] if textStartDetail.kind == "contextStart" && textStartDetail.span == detail.span { isAfterContextStart = true break } // Marker is ok to skip over if textStartDetail.span != nil { break } } // Skip contextId on span thats surrounded by context span immediately if !isAfterContextStart { if text == "" { text = fmt.Sprintf(`contextId: %v`, contextId) } else { text = fmt.Sprintf(`contextId: %v`, contextId) + `, ` + text } } } if text != "" { textWithContext.newContent.WriteString(`{ ` + text + ` |}`) } case "contextStart": if canDetermineContextIdInline { spanToContextId[*detail.span] = len(spanToContextId) } } if deferredMarkerIndex != nil && *deferredMarkerIndex == index { // Write the marker textWithContext.newContent.WriteString(options.markerName) deferredMarkerIndex = nil detail = details[0] // Marker detail } } if suffix, ok := detailSuffixes[detail]; ok { textWithContext.newContent.WriteString(suffix) } } textWithContext.add(nil) if textWithContext.newContent.Len() != 0 { textWithContext.readableContents.WriteString("\n") textWithContext.readableJsoncBaseline(textWithContext.newContent.String()) } return textWithContext.readableContents.String() } var lineSplitter = regexp.MustCompile(`\r?\n`) type textWithContext struct { nLinesContext int // number of context lines to write to baseline readableContents *strings.Builder // builds what will be returned to be written to baseline newContent *strings.Builder // helper; the part of the original file content to write between details pos lsproto.Position isLibFile bool fileName string content string // content of the original file lineStarts *lsconv.LSPLineMap converters *lsconv.Converters // posLineInfo posInfo *lsproto.Position lineInfo int } // implements lsconv.Script func (t *textWithContext) FileName() string { return t.fileName } // implements lsconv.Script func (t *textWithContext) Text() string { return t.content } func newTextWithContext(fileName string, content string) *textWithContext { t := &textWithContext{ nLinesContext: 4, readableContents: &strings.Builder{}, isLibFile: isLibFile(fileName), newContent: &strings.Builder{}, pos: lsproto.Position{Line: 0, Character: 0}, fileName: fileName, content: content, lineStarts: lsconv.ComputeLSPLineStarts(content), } t.converters = lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *lsconv.LSPLineMap { return t.lineStarts }) t.readableContents.WriteString("// === " + fileName + " ===") return t } func (t *textWithContext) add(detail *baselineDetail) { if t.content == "" && detail == nil { panic("Unsupported") } if detail == nil || (detail.kind != "textEnd" && detail.kind != "contextEnd") { // Calculate pos to location number of lines posLineIndex := t.lineInfo if t.posInfo == nil || *t.posInfo != t.pos { posLineIndex = t.lineStarts.ComputeIndexOfLineStart(t.converters.LineAndCharacterToPosition(t, t.pos)) } locationLineIndex := len(t.lineStarts.LineStarts) - 1 if detail != nil { locationLineIndex = t.lineStarts.ComputeIndexOfLineStart(t.converters.LineAndCharacterToPosition(t, detail.pos)) t.posInfo = &detail.pos t.lineInfo = locationLineIndex } nLines := 0 if t.newContent.Len() != 0 { nLines += t.nLinesContext + 1 } if detail != nil { nLines += t.nLinesContext + 1 } // first nLinesContext and last nLinesContext if locationLineIndex-posLineIndex > nLines { if t.newContent.Len() != 0 { var skippedString string if t.isLibFile { skippedString = "--- (line: --) skipped ---\n" } else { skippedString = fmt.Sprintf(`--- (line: %v) skipped ---`, posLineIndex+t.nLinesContext+1) } t.readableContents.WriteString("\n") t.readableJsoncBaseline(t.newContent.String() + t.sliceOfContent( t.getIndex(t.pos), t.getIndex(t.lineStarts.LineStarts[posLineIndex+t.nLinesContext]), ) + skippedString) if detail != nil { t.readableContents.WriteString("\n") } t.newContent.Reset() } if detail != nil { if t.isLibFile { t.newContent.WriteString("--- (line: --) skipped ---\n") } else { t.newContent.WriteString(fmt.Sprintf("--- (line: %v) skipped ---\n", locationLineIndex-t.nLinesContext+1)) } t.newContent.WriteString(t.sliceOfContent( t.getIndex(t.lineStarts.LineStarts[locationLineIndex-t.nLinesContext+1]), t.getIndex(detail.pos), )) } return } } if detail == nil { t.newContent.WriteString(t.sliceOfContent(t.getIndex(t.pos), nil)) } else { t.newContent.WriteString(t.sliceOfContent(t.getIndex(t.pos), t.getIndex(detail.pos))) } } func (t *textWithContext) readableJsoncBaseline(text string) { for i, line := range lineSplitter.Split(text, -1) { if i > 0 { t.readableContents.WriteString("\n") } t.readableContents.WriteString(`// ` + line) } } type markerAndItem[T any] struct { Marker *Marker `json:"marker"` Item T `json:"item"` } func annotateContentWithTooltips[T comparable]( t *testing.T, f *FourslashTest, markersAndItems []markerAndItem[T], opName string, getRange func(item T) *lsproto.Range, getTooltipLines func(item T, prev T) []string, ) string { barWithGutter := "| " + strings.Repeat("-", 70) // sort by file, then *backwards* by position in the file // so we can insert multiple times on a line without counting sorted := slices.Clone(markersAndItems) slices.SortFunc(sorted, func(a, b markerAndItem[T]) int { if c := cmp.Compare(a.Marker.FileName(), b.Marker.FileName()); c != 0 { return c } return -cmp.Compare(a.Marker.Position, b.Marker.Position) }) filesToLines := collections.NewOrderedMapWithSizeHint[string, []string](1) var previous T for _, itemAndMarker := range sorted { marker := itemAndMarker.Marker item := itemAndMarker.Item textRange := getRange(item) if textRange == nil { start := marker.LSPosition end := start end.Character = end.Character + 1 textRange = &lsproto.Range{Start: start, End: end} } if textRange.Start.Line != textRange.End.Line { t.Fatalf("Expected text range to be on a single line, got %v", textRange) } underline := strings.Repeat(" ", int(textRange.Start.Character)) + strings.Repeat("^", int(textRange.End.Character-textRange.Start.Character)) fileName := marker.FileName() lines, ok := filesToLines.Get(fileName) if !ok { lines = lineSplitter.Split(f.getScriptInfo(fileName).content, -1) } var tooltipLines []string if item != *new(T) { tooltipLines = getTooltipLines(item, previous) } if len(tooltipLines) == 0 { tooltipLines = []string{fmt.Sprintf("No %s at /*%s*/.", opName, *marker.Name)} } tooltipLines = core.Map(tooltipLines, func(line string) string { return "| " + line }) linesToInsert := make([]string, len(tooltipLines)+3) linesToInsert[0] = underline linesToInsert[1] = barWithGutter copy(linesToInsert[2:], tooltipLines) linesToInsert[len(linesToInsert)-1] = barWithGutter lines = slices.Insert( lines, int(textRange.Start.Line+1), linesToInsert..., ) filesToLines.Set(fileName, lines) previous = item } builder := strings.Builder{} seenFirst := false for fileName, lines := range filesToLines.Entries() { builder.WriteString(fmt.Sprintf("=== %s ===\n", fileName)) for _, line := range lines { builder.WriteString("// ") builder.WriteString(line) builder.WriteByte('\n') } if seenFirst { builder.WriteString("\n\n") } else { seenFirst = true } } return builder.String() } func (t *textWithContext) sliceOfContent(start *int, end *int) string { if start == nil || *start < 0 { start = ptrTo(0) } if end == nil || *end > len(t.content) { end = ptrTo(len(t.content)) } if *start > *end { return "" } return t.content[*start:*end] } func (t *textWithContext) getIndex(i any) *int { switch i := i.(type) { case *int: return i case int: return ptrTo(i) case core.TextPos: return ptrTo(int(i)) case *core.TextPos: return ptrTo(int(*i)) case lsproto.Position: return t.getIndex(t.converters.LineAndCharacterToPosition(t, i)) case *lsproto.Position: return t.getIndex(t.converters.LineAndCharacterToPosition(t, *i)) } panic(fmt.Sprintf("getIndex: unsupported type %T", i)) } func codeFence(lang string, code string) string { return "```" + lang + "\n" + code + "\n```" } func symbolInformationToData(symbol *lsproto.SymbolInformation) string { return fmt.Sprintf("{| name: %s, kind: %s |}", symbol.Name, symbol.Kind.String()) }