1183 lines
36 KiB
Go
1183 lines
36 KiB
Go
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"
|
|
closingTagCmd baselineCommand = "Closing Tag"
|
|
documentHighlightsCmd baselineCommand = "documentHighlights"
|
|
findAllReferencesCmd baselineCommand = "findAllReferences"
|
|
vsFindAllReferencesCmd baselineCommand = "vsFindAllReferences"
|
|
goToDefinitionCmd baselineCommand = "goToDefinition"
|
|
goToImplementationCmd baselineCommand = "goToImplementation"
|
|
goToSourceDefinitionCmd baselineCommand = "goToSourceDefinition"
|
|
goToTypeDefinitionCmd baselineCommand = "goToType"
|
|
inlayHintsCmd baselineCommand = "Inlay Hints"
|
|
nonSuggestionDiagnosticsCmd baselineCommand = "Syntax and Semantic Diagnostics"
|
|
quickInfoCmd baselineCommand = "QuickInfo"
|
|
linkedEditingCmd baselineCommand = "linkedEditing"
|
|
renameCmd baselineCommand = "findRenameLocations"
|
|
signatureHelpCmd baselineCommand = "SignatureHelp"
|
|
smartSelectionCmd baselineCommand = "Smart Selection"
|
|
codeLensesCmd baselineCommand = "Code Lenses"
|
|
documentSymbolsCmd baselineCommand = "Document Symbols"
|
|
)
|
|
|
|
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("// === ")
|
|
b.WriteString(string(command))
|
|
b.WriteString(" ===\n")
|
|
b.WriteString(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, documentSymbolsCmd, closingTagCmd, vsFindAllReferencesCmd:
|
|
return "baseline"
|
|
case callHierarchyCmd:
|
|
return "callHierarchy.txt"
|
|
case autoImportsCmd:
|
|
return "baseline.md"
|
|
case linkedEditingCmd:
|
|
return "linkedEditing.txt"
|
|
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, goToSourceDefinitionCmd:
|
|
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/", "")
|
|
},
|
|
}
|
|
case findAllReferencesCmd:
|
|
return baseline.Options{
|
|
Subfolder: subfolder,
|
|
IsSubmodule: true,
|
|
DiffFixupOld: func(s string) string {
|
|
var commandLines []string
|
|
commandPrefix := regexp.MustCompile(`^// === ([a-z\sA-Z]*) ===`)
|
|
filePrefix := regexp.MustCompile(`^// === ([^ ]*) ===`)
|
|
testFilePrefix := "/tests/cases/fourslash"
|
|
serverTestFilePrefix := "/server"
|
|
contextSpanOpening := "<|"
|
|
contextSpanClosing := "|>"
|
|
replacer := strings.NewReplacer(
|
|
testFilePrefix, "",
|
|
serverTestFilePrefix, "",
|
|
contextSpanOpening, "",
|
|
contextSpanClosing, "",
|
|
)
|
|
// Match location data like {| isWriteAccess: true, isDefinition: true |}
|
|
objectRangeRegex := regexp.MustCompile(`{\| [^|]* \|}`)
|
|
definitionsStr := "// === Definitions ==="
|
|
detailsStr := "// === Details ==="
|
|
lines := strings.Split(s, "\n")
|
|
var isInCommand bool
|
|
var isInDetails bool
|
|
var isInDefinitions bool
|
|
|
|
// Track file sections for sorting
|
|
type fileSection struct {
|
|
fileName string
|
|
lines []string
|
|
}
|
|
var fileSections []fileSection
|
|
var currentFileName string
|
|
var currentFileLines []string
|
|
|
|
for _, line := range lines {
|
|
matches := commandPrefix.FindStringSubmatch(line)
|
|
if len(matches) > 0 {
|
|
isInDetails = false
|
|
isInDefinitions = false
|
|
commandName := matches[1]
|
|
if commandName == string(findAllReferencesCmd) {
|
|
isInCommand = true
|
|
// Starting a new findAllReferences command block
|
|
if currentFileName != "" {
|
|
fileSections = append(fileSections, fileSection{fileName: currentFileName, lines: currentFileLines})
|
|
}
|
|
currentFileName = ""
|
|
currentFileLines = nil
|
|
slices.SortFunc(fileSections, func(a, b fileSection) int {
|
|
return strings.Compare(a.fileName, b.fileName)
|
|
})
|
|
for _, section := range fileSections {
|
|
section.lines = dropTrailingEmptyLines(section.lines)
|
|
commandLines = append(commandLines, section.lines...)
|
|
commandLines = append(commandLines, "")
|
|
}
|
|
fileSections = nil
|
|
if len(commandLines) > 0 {
|
|
commandLines = append(commandLines, "", "")
|
|
}
|
|
commandLines = append(commandLines, replacer.Replace(line))
|
|
continue
|
|
} else {
|
|
isInCommand = false
|
|
}
|
|
}
|
|
if isInCommand {
|
|
if strings.Contains(line, definitionsStr) || strings.Contains(line, detailsStr) {
|
|
isInDefinitions = strings.Contains(line, definitionsStr)
|
|
isInDetails = strings.Contains(line, detailsStr)
|
|
// Drop blank line before definitions/details
|
|
if len(currentFileLines) > 0 && currentFileLines[len(currentFileLines)-1] == "" {
|
|
currentFileLines = currentFileLines[:len(currentFileLines)-1]
|
|
}
|
|
}
|
|
// We don't diff the definitions or details sections
|
|
if !(isInDefinitions || isInDetails) {
|
|
fixedLine := replacer.Replace(line)
|
|
fixedLine = objectRangeRegex.ReplaceAllString(fixedLine, "")
|
|
|
|
fileMatches := filePrefix.FindStringSubmatch(fixedLine)
|
|
if len(fileMatches) > 0 {
|
|
if currentFileName != "" {
|
|
fileSections = append(fileSections, fileSection{fileName: currentFileName, lines: currentFileLines})
|
|
}
|
|
currentFileName = fileMatches[1]
|
|
currentFileLines = []string{fixedLine}
|
|
} else {
|
|
currentFileLines = append(currentFileLines, fixedLine)
|
|
}
|
|
} else if isInDetails && line == " ]" {
|
|
isInDetails = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save any remaining file section
|
|
if currentFileName != "" {
|
|
fileSections = append(fileSections, fileSection{fileName: currentFileName, lines: currentFileLines})
|
|
}
|
|
|
|
// Sort and add remaining file sections
|
|
if len(fileSections) > 0 {
|
|
slices.SortFunc(fileSections, func(a, b fileSection) int {
|
|
return strings.Compare(a.fileName, b.fileName)
|
|
})
|
|
for _, section := range fileSections {
|
|
section.lines = dropTrailingEmptyLines(section.lines)
|
|
commandLines = append(commandLines, section.lines...)
|
|
commandLines = append(commandLines, "")
|
|
}
|
|
}
|
|
|
|
return strings.Join(dropTrailingEmptyLines(commandLines), "\n")
|
|
},
|
|
}
|
|
case linkedEditingCmd:
|
|
deleteInfo := func(s string) string {
|
|
commandLines := []string{}
|
|
lines := strings.Split(s, "\n")
|
|
linkedEditingInfoHeader := regexp.MustCompile(`=== [0-9]+ ===`)
|
|
fileNameHeader := regexp.MustCompile(`=== [\w,\s-]+\.[A-Za-z]+ ===`)
|
|
inLinkedEditingInfo := false
|
|
for i, line := range lines {
|
|
if linkedEditingInfoHeader.MatchString(line) {
|
|
inLinkedEditingInfo = true
|
|
continue
|
|
}
|
|
if fileNameHeader.MatchString(line) {
|
|
inLinkedEditingInfo = false
|
|
continue
|
|
}
|
|
// drop the info since it's different--linked editing positions should be verified by file content/markers
|
|
if !inLinkedEditingInfo {
|
|
lines[i] = ""
|
|
}
|
|
}
|
|
return strings.Join(dropTrailingEmptyLines(commandLines), "\n")
|
|
}
|
|
return baseline.Options{
|
|
Subfolder: subfolder,
|
|
IsSubmodule: true,
|
|
DiffFixupOld: deleteInfo,
|
|
DiffFixupNew: deleteInfo,
|
|
}
|
|
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
|
|
preserveResultOrder bool
|
|
orderedFiles []lsproto.DocumentUri
|
|
}
|
|
|
|
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 })
|
|
if options.preserveResultOrder {
|
|
options.orderedFiles = uniqueFilesInSpanOrder(spans)
|
|
}
|
|
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{}
|
|
addFileEntry := func(path string) {
|
|
fileName := lsconv.FileNameToDocumentURI(path)
|
|
ranges := groupedRanges.Get(fileName)
|
|
if len(ranges) == 0 {
|
|
return
|
|
}
|
|
|
|
content, ok := f.textOfFile(path)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
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))
|
|
}
|
|
walkDirFn := func(path string, d vfs.DirEntry, e error) error {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
|
|
if !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
addFileEntry(path)
|
|
return nil
|
|
}
|
|
|
|
if options.preserveResultOrder {
|
|
for _, uri := range options.orderedFiles {
|
|
addFileEntry(uri.FileName())
|
|
}
|
|
} else {
|
|
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 uniqueFilesInSpanOrder(spans []documentSpan) []lsproto.DocumentUri {
|
|
if len(spans) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[lsproto.DocumentUri]struct{}{}
|
|
result := make([]lsproto.DocumentUri, 0, len(spans))
|
|
for _, span := range spans {
|
|
if _, ok := seen[span.uri]; ok {
|
|
continue
|
|
}
|
|
seen[span.uri] = struct{}{}
|
|
result = append(result, span.uri)
|
|
}
|
|
return result
|
|
}
|
|
|
|
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 detailKind int
|
|
|
|
const (
|
|
detailKindMarker detailKind = iota // /*MARKER*/
|
|
detailKindContextStart // <|
|
|
detailKindTextStart // [|
|
|
detailKindTextEnd // |]
|
|
detailKindContextEnd // |>
|
|
)
|
|
|
|
func (k detailKind) isEnd() bool {
|
|
return k == detailKindContextEnd || k == detailKindTextEnd
|
|
}
|
|
|
|
func (k detailKind) isStart() bool {
|
|
return k == detailKindContextStart || k == detailKindTextStart
|
|
}
|
|
|
|
type baselineDetail struct {
|
|
pos lsproto.Position
|
|
positionMarker string
|
|
span *documentSpan
|
|
kind detailKind
|
|
}
|
|
|
|
func (d *baselineDetail) getRange() lsproto.Range {
|
|
switch d.kind {
|
|
case detailKindContextStart:
|
|
return *d.span.contextSpan
|
|
case detailKindContextEnd:
|
|
return *d.span.contextSpan
|
|
case detailKindTextStart:
|
|
return d.span.textSpan
|
|
case detailKindTextEnd:
|
|
return d.span.textSpan
|
|
case detailKindMarker:
|
|
return lsproto.Range{
|
|
Start: d.pos,
|
|
End: d.pos,
|
|
}
|
|
default:
|
|
panic("unknown detail kind")
|
|
}
|
|
}
|
|
|
|
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: detailKindContextStart,
|
|
})
|
|
|
|
// 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: detailKindTextStart},
|
|
&baselineDetail{pos: span.textSpan.End, positionMarker: core.OrElse(options.endMarker, "|]"), span: &span, kind: detailKindTextEnd},
|
|
)
|
|
|
|
if span.contextSpan != nil {
|
|
details = append(details, &baselineDetail{
|
|
pos: span.contextSpan.End,
|
|
positionMarker: "|>",
|
|
span: &span,
|
|
kind: detailKindContextEnd,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Our preferred way to write markers is
|
|
// /*MARKER*/[| some text |]
|
|
// [| some /*MARKER*/ text |]
|
|
// [| some text |]/*MARKER*/
|
|
slices.SortStableFunc(details, func(d1, d2 *baselineDetail) int {
|
|
c := lsproto.ComparePositions(d1.pos, d2.pos)
|
|
if c != 0 || d1.kind == detailKindMarker && d2.kind == detailKindMarker {
|
|
return c
|
|
}
|
|
|
|
// /*MARKER*/[| some text |]
|
|
if d1.kind == detailKindMarker && d2.kind.isStart() {
|
|
return -1
|
|
}
|
|
if d2.kind == detailKindMarker && d1.kind.isStart() {
|
|
return 1
|
|
}
|
|
|
|
// [| some text |]/*MARKER*/
|
|
if d1.kind == detailKindMarker && d2.kind.isEnd() {
|
|
return 1
|
|
}
|
|
if d2.kind == detailKindMarker && d1.kind.isEnd() {
|
|
return -1
|
|
}
|
|
|
|
// [||] or <||>
|
|
if d1.span == d2.span {
|
|
return int(d1.kind - d2.kind)
|
|
}
|
|
|
|
// ...|><|...
|
|
if d1.kind.isStart() && d2.kind.isEnd() {
|
|
return 1
|
|
}
|
|
if d1.kind.isEnd() && d2.kind.isStart() {
|
|
return -1
|
|
}
|
|
|
|
// <| ... [| ... |]|>
|
|
if d1.kind.isEnd() && d2.kind.isEnd() {
|
|
c := lsproto.ComparePositions(d2.getRange().Start, d1.getRange().Start)
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
return int(d1.kind - d2.kind)
|
|
}
|
|
|
|
// <|[| ... |] ... |>
|
|
if d1.kind.isStart() && d2.kind.isStart() {
|
|
c := lsproto.ComparePositions(d2.getRange().End, d2.getRange().End)
|
|
if c != 0 {
|
|
return c
|
|
}
|
|
return int(d1.kind - d2.kind)
|
|
}
|
|
|
|
return 0
|
|
})
|
|
// !!! if canDetermineContextIdInline
|
|
|
|
textWithContext := newTextWithContext(fileName, content)
|
|
for index, detail := range details {
|
|
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 detailKindTextStart:
|
|
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 == detailKindContextStart && 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("{ ")
|
|
textWithContext.newContent.WriteString(text)
|
|
textWithContext.newContent.WriteString(" |}")
|
|
}
|
|
case detailKindContextStart:
|
|
if canDetermineContextIdInline {
|
|
spanToContextId[*detail.span] = len(spanToContextId)
|
|
}
|
|
}
|
|
}
|
|
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("// === ")
|
|
t.readableContents.WriteString(fileName)
|
|
t.readableContents.WriteString(" ===")
|
|
return t
|
|
}
|
|
|
|
func (t *textWithContext) add(detail *baselineDetail) {
|
|
if t.newContent.Len() == 0 && detail == nil {
|
|
panic("Unsupported")
|
|
}
|
|
if detail == nil || (detail.kind != detailKindTextEnd && detail.kind != detailKindContextEnd) {
|
|
// 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("// ")
|
|
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.SortStableFunc(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 = new(0)
|
|
}
|
|
|
|
if end == nil || *end > len(t.content) {
|
|
end = new(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 new(i)
|
|
case core.TextPos:
|
|
return new(int(i))
|
|
case *core.TextPos:
|
|
return new(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())
|
|
}
|