610 lines
18 KiB
Go
610 lines
18 KiB
Go
package fourslash
|
|
|
|
import (
|
|
"cmp"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/debug"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/ls"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/lsp/lsproto"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/stringutil"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/testutil/baseline"
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs"
|
|
)
|
|
|
|
func (f *FourslashTest) addResultToBaseline(t *testing.T, command string, actual string) {
|
|
b, ok := f.baselines[command]
|
|
if !ok {
|
|
f.baselines[command] = &strings.Builder{}
|
|
b = f.baselines[command]
|
|
}
|
|
if b.Len() != 0 {
|
|
b.WriteString("\n\n\n\n")
|
|
}
|
|
b.WriteString(`// === ` + command + " ===\n" + actual)
|
|
}
|
|
|
|
func (f *FourslashTest) writeToBaseline(command string, 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 string) string {
|
|
return getBaseFileNameFromTest(t) + "." + getBaselineExtension(command)
|
|
}
|
|
|
|
func getBaselineExtension(command string) string {
|
|
switch command {
|
|
case "QuickInfo", "SignatureHelp":
|
|
return "baseline"
|
|
case "Auto Imports":
|
|
return "baseline.md"
|
|
case "findAllReferences", "goToDefinition", "findRenameLocations":
|
|
return "baseline.jsonc"
|
|
default:
|
|
return "baseline.jsonc"
|
|
}
|
|
}
|
|
|
|
func getBaselineOptions(command string) baseline.Options {
|
|
subfolder := "fourslash/" + normalizeCommandName(command)
|
|
switch command {
|
|
case "findRenameLocations":
|
|
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 == command {
|
|
isInCommand = true
|
|
} else {
|
|
isInCommand = false
|
|
}
|
|
}
|
|
if isInCommand {
|
|
fixedLine := replacer.Replace(line)
|
|
commandLines = append(commandLines, fixedLine)
|
|
}
|
|
}
|
|
return strings.Join(commandLines, "\n")
|
|
},
|
|
}
|
|
default:
|
|
return baseline.Options{
|
|
Subfolder: subfolder,
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeCommandName(command string) string {
|
|
words := strings.Fields(command)
|
|
command = strings.Join(words, "")
|
|
return stringutil.LowerFirstChar(command)
|
|
}
|
|
|
|
type baselineFourslashLocationsOptions struct {
|
|
// markerInfo
|
|
marker MarkerOrRange // location
|
|
markerName string // name of the marker to be printed in baseline
|
|
|
|
endMarker string
|
|
|
|
startMarkerPrefix func(span lsproto.Location) *string
|
|
endMarkerSuffix func(span lsproto.Location) *string
|
|
}
|
|
|
|
func (f *FourslashTest) getBaselineForLocationsWithFileContents(spans []lsproto.Location, options baselineFourslashLocationsOptions) string {
|
|
locationsByFile := collections.GroupBy(spans, func(span lsproto.Location) lsproto.DocumentUri { return span.Uri })
|
|
rangesByFile := collections.MultiMap[lsproto.DocumentUri, lsproto.Range]{}
|
|
for file, locs := range locationsByFile.M {
|
|
for _, loc := range locs {
|
|
rangesByFile.Add(file, loc.Range)
|
|
}
|
|
}
|
|
return f.getBaselineForGroupedLocationsWithFileContents(
|
|
&rangesByFile,
|
|
options,
|
|
)
|
|
}
|
|
|
|
func (f *FourslashTest) getBaselineForGroupedLocationsWithFileContents(groupedRanges *collections.MultiMap[lsproto.DocumentUri, lsproto.Range], 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
|
|
|
|
baselineEntries := []string{}
|
|
err := f.vfs.WalkDir("/", func(path string, d vfs.DirEntry, e error) error {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
|
|
if !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
fileName := ls.FileNameToDocumentURI(path)
|
|
ranges := groupedRanges.Get(fileName)
|
|
if len(ranges) == 0 {
|
|
return nil
|
|
}
|
|
|
|
content, ok := f.vfs.ReadFile(path)
|
|
if !ok {
|
|
// !!! error?
|
|
return nil
|
|
}
|
|
|
|
if options.marker != nil && options.marker.FileName() == path {
|
|
foundMarker = true
|
|
}
|
|
|
|
baselineEntries = append(baselineEntries, f.getBaselineContentForFile(path, content, ranges, nil, options))
|
|
return nil
|
|
})
|
|
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
panic("walkdir error during fourslash baseline: " + err.Error())
|
|
}
|
|
|
|
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.vfs.ReadFile(markerFileName); ok {
|
|
baselineEntries = append(baselineEntries, f.getBaselineContentForFile(markerFileName, content, nil, nil, options))
|
|
}
|
|
}
|
|
|
|
// !!! foundAdditionalSpan
|
|
// !!! skipDocumentContainingOnlyMarker
|
|
|
|
return strings.Join(baselineEntries, "\n\n")
|
|
}
|
|
|
|
type baselineDetail struct {
|
|
pos lsproto.Position
|
|
positionMarker string
|
|
span *lsproto.Range
|
|
kind string
|
|
}
|
|
|
|
func (f *FourslashTest) getBaselineContentForFile(
|
|
fileName string,
|
|
content string,
|
|
spansInFile []lsproto.Range,
|
|
spanToContextId map[lsproto.Range]int,
|
|
options baselineFourslashLocationsOptions,
|
|
) string {
|
|
details := []*baselineDetail{}
|
|
detailPrefixes := map[*baselineDetail]string{}
|
|
detailSuffixes := map[*baselineDetail]string{}
|
|
canDetermineContextIdInline := true
|
|
uri := ls.FileNameToDocumentURI(fileName)
|
|
|
|
if options.marker != nil && options.marker.FileName() == fileName {
|
|
details = append(details, &baselineDetail{pos: options.marker.LSPos(), positionMarker: options.markerName})
|
|
}
|
|
|
|
for _, span := range spansInFile {
|
|
textSpanIndex := len(details)
|
|
details = append(details,
|
|
&baselineDetail{pos: span.Start, positionMarker: "[|", span: &span, kind: "textStart"},
|
|
&baselineDetail{pos: span.End, positionMarker: core.OrElse(options.endMarker, "|]"), span: &span, kind: "textEnd"},
|
|
)
|
|
|
|
if options.startMarkerPrefix != nil {
|
|
startPrefix := options.startMarkerPrefix(lsproto.Location{Uri: uri, Range: 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.Start == options.marker.LSPos() {
|
|
_, ok := detailPrefixes[details[0]]
|
|
debug.Assert(!ok, "Expected only single prefix at marker location")
|
|
detailPrefixes[details[0]] = *startPrefix
|
|
} else {
|
|
detailPrefixes[details[textSpanIndex]] = *startPrefix
|
|
}
|
|
}
|
|
}
|
|
|
|
if options.endMarkerSuffix != nil {
|
|
endSuffix := options.endMarkerSuffix(lsproto.Location{Uri: uri, Range: span})
|
|
if endSuffix != nil {
|
|
detailSuffixes[details[textSpanIndex+1]] = *endSuffix
|
|
}
|
|
}
|
|
}
|
|
|
|
slices.SortStableFunc(details, func(d1, d2 *baselineDetail) int {
|
|
return ls.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 *ls.LSPLineMap
|
|
converters *ls.Converters
|
|
|
|
// posLineInfo
|
|
posInfo *lsproto.Position
|
|
lineInfo int
|
|
}
|
|
|
|
// implements ls.Script
|
|
func (t *textWithContext) FileName() string {
|
|
return t.fileName
|
|
}
|
|
|
|
// implements ls.Script
|
|
func (t *textWithContext) Text() string {
|
|
return t.content
|
|
}
|
|
|
|
func newTextWithContext(fileName string, content string) *textWithContext {
|
|
t := &textWithContext{
|
|
nLinesContext: 4,
|
|
|
|
readableContents: &strings.Builder{},
|
|
|
|
isLibFile: regexp.MustCompile(`lib.*\.d\.ts$`).MatchString(fileName),
|
|
newContent: &strings.Builder{},
|
|
pos: lsproto.Position{Line: 0, Character: 0},
|
|
fileName: fileName,
|
|
content: content,
|
|
lineStarts: ls.ComputeLSPLineStarts(content),
|
|
}
|
|
|
|
t.converters = ls.NewConverters(lsproto.PositionEncodingKindUTF8, func(_ string) *ls.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 interface{}) *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```"
|
|
}
|