2025-10-15 10:12:44 +03:00

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```"
}